<?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="https://ellell.gitlab.io/webverse_pro/feed.xml" rel="self" type="application/atom+xml" /><link href="https://ellell.gitlab.io/webverse_pro/" rel="alternate" type="text/html" hreflang="en" /><updated>2026-09-13T20:10:58+00:00</updated><id>https://ellell.gitlab.io/webverse_pro/feed.xml</id><title type="html">WebVerse Pro Writeups</title><subtitle>Writeups from WebVerse CTF challenges.</subtitle><author><name>ellell</name></author><entry><title type="html">HalcyonFM</title><link href="https://ellell.gitlab.io/webverse_pro/halcyonfm/" rel="alternate" type="text/html" title="HalcyonFM" /><published>2026-07-20T00:00:00+00:00</published><updated>2026-07-20T00:00:00+00:00</updated><id>https://ellell.gitlab.io/webverse_pro/halcyonfm</id><content type="html" xml:base="https://ellell.gitlab.io/webverse_pro/halcyonfm/"><![CDATA[<p>A community radio station that stacks five vulnerabilities, each one individually mitigated,
into a single unbroken chain from an anonymous visitor to command execution.</p>

<p><em>Spoiler-light notes — no flags, credentials or copy-paste exploits. Solve it yourself first.</em></p>

<!-- more -->

<h2 id="recon">Recon</h2>

<p>A single open port behind nginx, and the usual pattern for this platform: the default
response is a redirect that names the vhost you’re supposed to be using. Once that’s in the
hosts file you get a station homepage with a schedule, show pages and a “submit your demo”
form.</p>

<p>The demo form has a <strong>preview</strong> step that fetches a user-supplied artwork or feed URL
server-side. That’s the attack surface, and the client bundle tells you how to use it: the
request body carries a <strong>developer flag</strong>, and the embedded comment explains that the full
response body is only returned when that flag is set.</p>

<p>Flipping it converts a blind fetcher into a <strong>full-read SSRF</strong> — you get the status, the
content type and the complete body back. That single toggle is the difference between port
guessing and actually reading internal applications.</p>

<h2 id="from-ssrf-to-an-internal-api">From SSRF to an internal API</h2>

<p>Pointing the fetcher at loopback and sweeping ports surfaced an <strong>undocumented internal API</strong>
alongside the public app. Its spec document was served over the same interface, which handed
over the whole surface: a user listing endpoint, a password-change endpoint (POST-only), and
a <strong>diagnostics “stream probe”</strong> whose entire job is to shell out to a network client and
report reachability.</p>

<p>The password change was the goal but it required POST, and the SSRF only issues GET. The
diagnostics endpoint was the pivot — and it was vulnerable.</p>

<h2 id="command-injection">Command injection</h2>

<p>The stream probe concatenated the caller-supplied URL straight into a shell command string
and ran it. A trailing <code class="language-plaintext highlighter-rouge">; sleep</code> confirmed injection by shifting the response time; because
the endpoint returns the raw stdout of the whole pipeline, any injected command’s output
<strong>comes back in the JSON response</strong>. That’s a comfortable RCE — you get results, not just a
shell.</p>

<p>The source even carried a comment explaining that the parameter was considered trusted
because the API only listens on loopback. Loopback is not authentication.</p>

<h2 id="the-proc-scrubbing-trap">The <code class="language-plaintext highlighter-rouge">/proc</code> scrubbing trap</h2>

<p>This is the best lesson on the box. The API reads its database credentials from environment
variables and then <strong>deliberately removes them from its own environment</strong>, specifically so
that a shell spawned by the diagnostics endpoint can’t recover them. Sensible instinct,
wrong assumption.</p>

<p>On Linux, <code class="language-plaintext highlighter-rouge">/proc/&lt;pid&gt;/environ</code> is a <strong>static snapshot written by the kernel at <code class="language-plaintext highlighter-rouge">execve()</code></strong>.
Removing a variable from the running process updates the live environment — it does <strong>not</strong>
update that snapshot. The original values stay readable for the lifetime of the process, so
reading the service’s own <code class="language-plaintext highlighter-rouge">/proc</code> entry handed back the database password intact.</p>

<p>Then the trap reverses direction: with command execution on the host, I no longer needed the
SSRF to reach the internal API — I could make the POST to the password-change endpoint
directly over loopback and reset the administrator’s password.</p>

<h2 id="the-hidden-console">The hidden console</h2>

<p>The container’s <code class="language-plaintext highlighter-rouge">/etc/hosts</code> listed a <strong>second vhost that the public site never links to</strong> —
a staff playout console. Logging in with the freshly reset administrator password worked,
which is the payoff for chaining the previous two steps: the SSRF found the service, the
injection reset the account.</p>

<p>Its playlist import endpoint deserialised the uploaded bundle with the language’s native
object-serialisation format and — critically — <strong>rendered the deserialised object back into
the response</strong>. That combination turns insecure deserialization from a blind exploit into one
where the command’s output is printed on the page. Upload, read the flag inline.</p>

<p>Note the format detail: the bundle was base64-wrapped before deserialization, matching the
export format the app itself produced. Reading the export endpoint first told me exactly what
the import endpoint expected.</p>

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

<ul>
  <li><strong><code class="language-plaintext highlighter-rouge">/proc/&lt;pid&gt;/environ</code> is a snapshot, not a view.</strong> Scrubbing secrets from the live
environment does nothing to it. Never rely on it as a mitigation — and read it when you
have code execution.</li>
  <li>A “developer” or “debug” flag in a request body can upgrade a blind primitive into a
full-read one. Grep client bundles for them.</li>
  <li><strong>Loopback binding is not authentication</strong>, and a service that shells out with caller input
is command execution regardless of where it listens.</li>
  <li>Chaining beats depth: the SSRF found the endpoint, but the <em>injection</em> was what actually
reset the password. Keep both primitives alive.</li>
  <li>Insecure deserialization is much easier to exploit when the app echoes the deserialised
object back to you — check whether the result is rendered.</li>
  <li>Five partial mitigations still add up to a full compromise. Each step here was “mostly
handled”, and the chain walked straight through.</li>
</ul>]]></content><author><name>ellell</name></author><category term="web" /><category term="ssrf" /><category term="command-injection" /><category term="deserialization" /><category term="credential-exposure" /><summary type="html"><![CDATA[A community radio station that stacks five vulnerabilities, each one individually mitigated, into a single unbroken chain from an anonymous visitor to command execution. Spoiler-light notes — no flags, credentials or copy-paste exploits. Solve it yourself first.]]></summary></entry><entry><title type="html">Inked</title><link href="https://ellell.gitlab.io/webverse_pro/inked/" rel="alternate" type="text/html" title="Inked" /><published>2026-07-19T00:00:00+00:00</published><updated>2026-07-19T00:00:00+00:00</updated><id>https://ellell.gitlab.io/webverse_pro/inked</id><content type="html" xml:base="https://ellell.gitlab.io/webverse_pro/inked/"><![CDATA[<p>A tattoo studio’s booking site that declared its own vulnerability in a CSS comment. The
obvious path was a decoy — the real way in was a poisoned password-reset email and a
templating engine that would happily run shell commands for you.</p>

<p><em>Spoiler-light notes — no flags, credentials or copy-paste exploits. Solve it yourself first.</em></p>

<!-- more -->

<h2 id="recon">Recon</h2>

<p>One nginx reverse proxy routing purely on the <code class="language-plaintext highlighter-rouge">Host</code> header, and behind it <strong>two separate
applications</strong>: a public shop with a booking form, and a staff console with its own login.
Both are invisible until the vhost names are in your hosts file.</p>

<p>Two details paid off immediately, and neither came from a scanner:</p>

<ul>
  <li>The staff console’s CSS carried a comment labelling one section as the vulnerable
surface — the author leaving breadcrumbs. I followed it, and it was a red herring (see
below), but it told me the <em>shape</em> of the challenge.</li>
  <li>The console’s JavaScript pointed at a <strong>second API host</strong> that the UI never mentions
directly. Always read the client bundle once you’re authenticated; the interesting
backend is rarely the one you logged in to.</li>
</ul>

<h2 id="the-decoy-stored-xss">The decoy: stored XSS</h2>

<p>Everything pointed at stored XSS through the booking form, and it was <em>real</em>: the console
rendered client notes as raw HTML, an automated browser was visiting submissions, and the
CSS comment named the section.</p>

<p>It was still the wrong path — the bot’s network couldn’t reach my callback address. Thirty
payloads, zero callbacks. <strong>If you can’t receive the callback, the bug is untestable, not
unexploitable.</strong> I noted it, stopped, and moved on, which in retrospect was the right call.</p>

<h2 id="password-reset-poisoning-with-a-helpful-mail-daemon">Password-reset poisoning with a helpful mail daemon</h2>

<p>The staff login had a “forgot password” link, and the reset URL was built from the request’s
host header rather than a configured base URL. Injecting an alternate host into that header
made the app generate a reset link pointing at <em>my</em> listener.</p>

<p>That alone is a known technique, but the interesting part was what happened next: the
studio’s internal mail daemon <strong>pre-fetches URLs in outgoing messages</strong> (link previews). It
requested the poisoned link within seconds of the reset request being submitted — so the
reset token arrived at my listener <strong>without the user ever opening the email</strong>.</p>

<p>That’s what makes this pattern worth remembering: it isn’t just developer oversight on the
web side. It works because an internal service follows links, which turns a low-key bug into
an instant, no-interaction token theft.</p>

<p>With the token, resetting the staff account’s password and logging into the console was
mechanical.</p>

<h2 id="the-real-bug-server-side-template-rendering">The real bug: server-side template rendering</h2>

<p>Authenticated, the booking notes were rendered <strong>server-side through a templating engine</strong>
before being returned as HTML fragments. A trivial arithmetic expression in the note field
came back evaluated — text-book server-side template injection.</p>

<p>The puzzle was getting <em>output</em> out, not execution. The API wraps rendering in output
buffering, so functions that print to stdout have their output discarded — the template
receives nothing. Only a callable that <strong>returns</strong> its output as a string survives.</p>

<p>That turns into an argument-count problem. Each template filter invokes its callback with a
fixed number of arguments — one filter passes one, another passes two, another passes three
— and you need a function whose signature matches that count <em>and</em> whose return value is the
data you want. One of PHP’s process-execution builtins has exactly the right shape, and one
filter passes exactly that many arguments, so the command’s output flows straight back into
the template output.</p>

<p>Standard payloads fail for exactly this reason: the well-known “register an undefined
callback” macro trick is gone in newer builds, and arrow functions can reference template
functions but not arbitrary language callables. <strong>Arg-count alignment is the whole
technique</strong> — enumerate the filters, count their arguments, and find a callable that fits.</p>

<h2 id="dead-ends-worth-recording">Dead ends worth recording</h2>

<ul>
  <li><strong>JWT <code class="language-plaintext highlighter-rouge">alg:none</code></strong> — the server validates the signature even when the header claims no
algorithm. Always test it, but don’t assume it works.</li>
  <li><strong>SQL injection in the style field</strong> — an automated agent reported a time-based
confirmation; careful re-timing showed the delay was network jitter. A textbook reason to
re-verify anything time-based by hand.</li>
  <li><strong>Password brute force</strong> — a hundred common passwords, no lockout, no hit. The real
password simply wasn’t in the wordlist, which is itself a useful signal: stop guessing and
find the intended path.</li>
</ul>

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

<ul>
  <li><strong>Read the application’s own artefacts before scanning</strong>: response headers, source
comments, and client-side JS files pointed at every step of this chain.</li>
  <li>Password-reset host-header poisoning is underrated — especially when an internal mail
service follows links. No user interaction required.</li>
  <li>In template injection, <strong>getting output is a separate problem from getting execution</strong>.
Output buffering kills every stdout-based payload; look for a callable whose <em>return value</em>
is the data.</li>
  <li>Match the callback’s argument count to the filter that invokes it. That’s the difference
between a payload that errors and one that prints.</li>
  <li>Chasing a signposted decoy costs time — but confirm it quickly, note it, and leave.</li>
</ul>]]></content><author><name>ellell</name></author><category term="web" /><category term="host-header-injection" /><category term="password-reset" /><category term="ssti" /><category term="rce" /><summary type="html"><![CDATA[A tattoo studio’s booking site that declared its own vulnerability in a CSS comment. The obvious path was a decoy — the real way in was a poisoned password-reset email and a templating engine that would happily run shell commands for you. Spoiler-light notes — no flags, credentials or copy-paste exploits. Solve it yourself first.]]></summary></entry><entry><title type="html">PhoneVault</title><link href="https://ellell.gitlab.io/webverse_pro/phonevault/" rel="alternate" type="text/html" title="PhoneVault" /><published>2026-07-19T00:00:00+00:00</published><updated>2026-07-19T00:00:00+00:00</updated><id>https://ellell.gitlab.io/webverse_pro/phonevault</id><content type="html" xml:base="https://ellell.gitlab.io/webverse_pro/phonevault/"><![CDATA[<p>An online storefront whose developer left the full vulnerability roadmap sitting in the web
root as a to-do list — and then relied on a Content Security Policy to cover the parts they
hadn’t finished.</p>

<p><em>Spoiler-light notes — no flags, credentials or copy-paste exploits. Solve it yourself first.</em></p>

<!-- more -->

<h2 id="recon">Recon</h2>

<p>A single web port behind a reverse proxy, reachable only by its virtual hostname. Enumeration
turns up two files worth more than everything else combined:</p>

<ul>
  <li><strong>A publicly readable developer notes file.</strong> It’s a to-do list, and it names three deferred
security items in plain language — a reflective endpoint that isn’t validated, a review
feature that renders raw markup, and an admin cookie deliberately left without <code class="language-plaintext highlighter-rouge">HttpOnly</code>.
That file maps the entire attack chain before a single request is crafted.</li>
  <li><strong>A front-end search implementation</strong> that reveals the app uses a callback-style JSON API —
the kind that wraps its response in a client-supplied function name.</li>
</ul>

<p>The notes file is also candid about <em>why</em> each item was deferred. The reflective endpoint was
low priority “since the CSP should prevent abuse”. It’s worth pausing on that sentence, because
it’s the entire box.</p>

<h2 id="the-csp-and-the-misconception-in-it">The CSP, and the misconception in it</h2>

<p>The site ships a strict, well-written policy: scripts limited to the same origin, no inline
scripts, <code class="language-plaintext highlighter-rouge">object-src 'none'</code>, <code class="language-plaintext highlighter-rouge">base-uri 'none'</code>, <code class="language-plaintext highlighter-rouge">frame-ancestors 'none'</code>, and connections
limited to the same origin.</p>

<p>That policy is genuinely good. What it does <strong>not</strong> do is stop the page from loading a script
<strong>from its own origin</strong> — including from an endpoint whose response body is shaped by an
attacker-supplied parameter.</p>

<p><code class="language-plaintext highlighter-rouge">script-src 'self'</code> bans <em>other origins</em> and <em>inline</em> scripts. It has nothing to say about
loading a script from <strong>your own origin</strong> — that’s the origin it explicitly permits. So if any
same-origin endpoint reflects attacker-controlled text in a script-shaped response, that
endpoint <em>is</em> your script loader, and the policy is fully satisfied while arbitrary JavaScript
executes.</p>

<p>The developer spotted that the callback-style endpoint was dangerous, then treated the CSP as
a compensating control. It never was one.</p>

<h2 id="the-three-bugs">The three bugs</h2>

<ol>
  <li><strong>The callback parameter is reflected verbatim.</strong> The JSON API wraps its response in
whatever function name you supply, with no validation or allowlist. Supplying a value that
ends in a line comment neatly discards the JSON that follows, leaving a clean block of
executable JavaScript.</li>
  <li><strong>Reviews render as raw markup.</strong> Any authenticated user can post a review, and the body is
inserted into the page unescaped — the comment in the template even says it’s “to support
rich text formatting”.</li>
  <li><strong>The admin’s session cookie isn’t <code class="language-plaintext highlighter-rouge">HttpOnly</code></strong> — and the flag lives in that cookie. The
notes file admits it was left readable so the admin dashboard’s own JavaScript could read
it.</li>
</ol>

<p>Any one of those alone is a bug. Together they’re a chain, and each one removes the obstacle
the previous one creates.</p>

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

<ol>
  <li><strong>Authenticate.</strong> The app ships default credentials, and the notes file helpfully confirms
passwords are stored in plaintext. No cracking needed.</li>
  <li><strong>Store the payload as a review.</strong> The review body is a script tag whose source is the
<em>same-origin</em> JSON API, with the callback parameter carrying the JavaScript we want to run.
Because the source is same-origin, the CSP allows it.</li>
  <li><strong>Have the callback do the exfiltration.</strong> The injected JavaScript reads <code class="language-plaintext highlighter-rouge">document.cookie</code>
(which contains the admin’s flag cookie) and posts it as a <strong>review on a different
product</strong> — using credentials-included so the request is authenticated, and a form-encoded
body so no preflight is involved. Writing the stolen value into another product’s review
gives us a <strong>second-order read channel</strong>: the exfiltrated secret comes back out through the
normal application UI.</li>
  <li><strong>Trigger the admin bot.</strong> The storefront has a “report this page” feature that sends an
admin bot to review a URL. Reporting our poisoned product page is the delivery mechanism.</li>
  <li><strong>Collect and replay.</strong> The bot loads the page, the same-origin script loads and executes,
and the admin’s cookie is written into the other product’s reviews where we can read it.
Replay that cookie against the admin panel and the flag is displayed.</li>
</ol>

<p>Notice what the exfiltration <em>doesn’t</em> need: no outbound connection to an attacker host, no
DNS, no server of our own. Everything happens within the permitted origin. That’s precisely
why the CSP didn’t help.</p>

<h2 id="dead-ends">Dead ends</h2>

<ul>
  <li><strong>Sending the cookie to an external collector</strong> — blocked, as the policy intends:
<code class="language-plaintext highlighter-rouge">connect-src</code> is same-origin only, which also rules out <code class="language-plaintext highlighter-rouge">sendBeacon</code> to an outside host.</li>
  <li><strong>Image-based exfiltration</strong> — the policy allows HTTPS images but a plaintext cookie in a URL
needs plain HTTP, which would be a downgrade the browser won’t make.</li>
  <li><strong>Hunting a JSON API that returns the flag</strong> — the non-admin routes simply fail; the secret
is only ever rendered in the admin HTML panel.</li>
  <li><strong>Injection at the login</strong> — malformed bodies crash or reset the worker rather than
returning anything useful.</li>
</ul>

<p>Each of these pushes you back toward the same conclusion: exfiltrate <em>through</em> the application,
over the channel you’re already allowed to use.</p>

<h2 id="why-it-worked">Why it worked</h2>

<p>All three failures share one root cause: the developer evaluated each risk in isolation and
assumed a different control would catch it later. The reflective endpoint was “fine because
CSP”. The raw review rendering was “fine because you must be logged in”. The cookie was
non-<code class="language-plaintext highlighter-rouge">HttpOnly</code> “for convenience”. Individually, each mitigation sounds reasonable. Stacked,
they cancel out — the raw rendering gives you script execution, the reflective endpoint gives
you a way to run code the CSP permits, and the readable cookie gives you the prize.</p>

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

<ul>
  <li><strong><code class="language-plaintext highlighter-rouge">script-src 'self'</code> does not stop same-origin script injection.</strong> Any endpoint that
reflects input into a script-shaped response is a script loader. JSONP is the classic case,
and CSP cannot compensate for it — validate or remove the reflective parameter.</li>
  <li><strong>Never ship a developer notes or to-do file in the web root.</strong> It collapsed hours of
enumeration into a two-minute read, and it told us which mitigations the developer <em>believed</em>
were protecting them.</li>
  <li><strong>Encode on output, in every view</strong> — internal-looking pages are exactly the ones with
privileged readers.</li>
  <li><strong><code class="language-plaintext highlighter-rouge">HttpOnly</code> on every session cookie.</strong> Don’t leave secrets readable from JavaScript for
convenience.</li>
  <li><strong>Second-order channels work.</strong> Writing a stolen value back into the application’s own data
(a review, a comment, a profile field) is a reliable read-back when outbound traffic is
blocked.</li>
  <li><strong>Hash passwords.</strong> Plaintext storage turned a default-credentials issue into total account
compromise.</li>
</ul>]]></content><author><name>ellell</name></author><category term="web" /><category term="xss" /><category term="csp-bypass" /><category term="jsonp" /><category term="session-hijacking" /><summary type="html"><![CDATA[An online storefront whose developer left the full vulnerability roadmap sitting in the web root as a to-do list — and then relied on a Content Security Policy to cover the parts they hadn’t finished. Spoiler-light notes — no flags, credentials or copy-paste exploits. Solve it yourself first.]]></summary></entry><entry><title type="html">ReportVerse</title><link href="https://ellell.gitlab.io/webverse_pro/reportverse/" rel="alternate" type="text/html" title="ReportVerse" /><published>2026-07-19T00:00:00+00:00</published><updated>2026-07-19T00:00:00+00:00</updated><id>https://ellell.gitlab.io/webverse_pro/reportverse</id><content type="html" xml:base="https://ellell.gitlab.io/webverse_pro/reportverse/"><![CDATA[<p>A “professional report builder” that proudly advertises it will fetch any URL you give it,
server-side, and embed the result in a PDF. It is not wrong about that. The internal dashboard
it can reach is the problem.</p>

<p><em>Spoiler-light notes — no flags, credentials or copy-paste exploits. Solve it yourself first.</em></p>

<!-- more -->

<h2 id="recon">Recon</h2>

<p>One web port, one vhost handed to you by a redirect, and a Node application behind it.</p>

<p>The landing page is a report generator with a few template choices and one field that stands
out: an “external data endpoint”. The marketing copy next to it says, in as many words, that
the URL is fetched <strong>from the server</strong>, that this “works with internal staging APIs,
authenticated endpoints behind your firewall, or any URL not publicly accessible”, and that the
response is embedded directly into the generated PDF.</p>

<p>An application describing its own SSRF vector is not a subtle bug hunt — but “the app fetches
URLs” is the start of a process, not the answer. What you do with that primitive is the box.</p>

<h2 id="building-the-primitive">Building the primitive</h2>

<p><code class="language-plaintext highlighter-rouge">file://</code> and other schemes are rejected — only HTTP and HTTPS are accepted. That’s a real
limit, and it removes the easiest escalations, so the useful question becomes: <strong>what is
reachable over HTTP from inside this host?</strong></p>

<p>The generator renders the fetched content into a PDF and hands you the file. That’s the detail
that makes this box pleasant to work: <strong>the SSRF is full-read, not blind.</strong> A blind SSRF only
lets you infer (“something answered”); here the fetched page is rendered and returned to you,
so anything the internal service will render to a browser comes back as document text. The
renderer is acting as your exfiltration channel, and it works even against services that would
never let you talk to them directly.</p>

<p>The PDF is also the reason <code class="language-plaintext highlighter-rouge">file://</code> matters less than it looks. A blocked file scheme costs
you direct filesystem reads, but every HTTP-accessible service inside the host remains in
scope — and those are where credentials and internal dashboards actually live.</p>

<p>That rendered file also gives a side channel: the <em>size</em> of the response body differs
depending on what answered. Sweeping
common localhost ports and comparing output sizes turns a blind fetch into a port oracle — a
port that isn’t listening produces an error or a timeout, and a port that is listening
produces a PDF whose size grows with how much content it rendered. One port came back
markedly larger than the rest, which is enough to know something substantive lives there.</p>

<h2 id="reading-a-pdf-that-wont-give-up-its-text">Reading a PDF that won’t give up its text</h2>

<p>The PDFs were not extractable with the usual command-line tool — it returned nothing at all,
which reads like an empty document but isn’t. The renderer embeds CID-mapped fonts: glyphs are
referenced by font-internal identifiers rather than Unicode codepoints, so a naive extractor
cannot map them back to characters without the font’s <code class="language-plaintext highlighter-rouge">ToUnicode</code> tables.</p>

<p>A more thorough PDF parser reads those CMap streams and reassembles the text correctly.
<strong>The lesson is not to trust a blank extraction result</strong>: “no text found” and “text I can’t
decode” look identical from the outside, and the difference matters enormously when the entire
objective is text sitting inside the PDF.</p>

<h2 id="what-was-behind-the-port">What was behind the port</h2>

<p>The internal service is a metrics dashboard bound to loopback — and it has <strong>no
authentication at all</strong>. Its own text explains why: “Authentication is disabled — network
isolation is assumed.” That is the entire bug in one sentence. The developers substituted the
network boundary for an authorisation check, and the SSRF removed the network boundary.</p>

<p>The dashboard hands over the internal service topology (including which services have auth and
which don’t) alongside the credential the box is asking for. That topology leak is worth more
than the flag: it’s a map of what else is reachable, which listeners are bound where, and —
crucially — which ones have no authentication. An SSRF that returns that map is an SSRF you
can chain, and the “beyond the flag” question (is there a cache, a database, another app on
this network?) is answered for you.</p>

<p>Worth noting what the write-up <em>didn’t</em> need: nothing was brute-forced, no credential was
cracked, and no code was executed. Two design decisions — fetch any URL server-side, and
protect an internal service with network isolation instead of authentication — were sufficient
on their own.</p>

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

<ul>
  <li>Server-side fetch features are SSRF by construction — especially in PDF/screenshot/render
pipelines, where the response is returned to you as a file.</li>
  <li>Response <strong>size</strong> is a usable oracle. When you can’t read the content back, differential
sizes still enumerate ports and identify interesting services.</li>
  <li>A blank text extraction is not an empty document. If a PDF resists extraction, suspect
CID-mapped fonts and reach for a parser that honours <code class="language-plaintext highlighter-rouge">ToUnicode</code> maps.</li>
  <li>“Network isolation is assumed” is not authentication. Any SSRF, misrouted proxy or
request-smuggling bug converts an assumed boundary into an actual exposure.</li>
  <li>Enumerate every scheme the fetcher allows — a blocked <code class="language-plaintext highlighter-rouge">file://</code> narrows the attack, it
doesn’t close it.</li>
</ul>]]></content><author><name>ellell</name></author><category term="web" /><category term="ssrf" /><category term="pdf" /><category term="reconnaissance" /><summary type="html"><![CDATA[A “professional report builder” that proudly advertises it will fetch any URL you give it, server-side, and embed the result in a PDF. It is not wrong about that. The internal dashboard it can reach is the problem. Spoiler-light notes — no flags, credentials or copy-paste exploits. Solve it yourself first.]]></summary></entry><entry><title type="html">BombThreat</title><link href="https://ellell.gitlab.io/webverse_pro/bombthreat/" rel="alternate" type="text/html" title="BombThreat" /><published>2026-07-18T00:00:00+00:00</published><updated>2026-07-18T00:00:00+00:00</updated><id>https://ellell.gitlab.io/webverse_pro/bombthreat</id><content type="html" xml:base="https://ellell.gitlab.io/webverse_pro/bombthreat/"><![CDATA[<p>A fictional “detonation portal” where reaching the top clearance level is the whole objective —
and three small, independently boring bugs chain into it without a single exploit that would
survive a code review.</p>

<p><em>Spoiler-light notes — no flags, credentials or copy-paste exploits. Solve it yourself first.</em></p>

<!-- more -->

<h2 id="recon">Recon</h2>

<p>The target is a small REST API behind a single web port. Three routes matter: a login, an
MFA verification step, and the privileged action the whole box is built around.</p>

<p>That’s a deliberately narrow surface, and it tells you what the box is about before you send a
single request. A login route, a second factor, and one action gated behind a “clearance”
claim is an <strong>authentication and authorisation puzzle</strong> — not a hunt for a forgotten endpoint.
When the surface is this small, the attack is almost always against the <em>logic</em> of the flow:
who decides what you are, and how much of that decision the server actually re-checks.</p>

<p>The first useful find isn’t technical — it’s a comment left in the login page source, telling
users that login errors with valid credentials are a known issue that “will be fixed within
365 days”. A note like that is an invitation to attack the authentication path specifically,
and it’s the kind of thing that ships to production constantly.</p>

<h2 id="bug-one--the-injectable-field-is-not-the-obvious-one">Bug one — the injectable field is not the obvious one</h2>

<p>The login takes a username and a password. The username is sanitised or uses a different query
path; the password is interpolated straight into the query. Testing only the username would
have produced a clean “not vulnerable” verdict on an endpoint that is trivially bypassable.</p>

<p><strong>That’s the per-parameter lesson this box exists to teach:</strong> a negative result on one input is
not a negative result for the endpoint. Fuzz every field, every parameter, every header —
they frequently reach different queries.</p>

<p>A classic tautology in the password field authenticates you as an existing low-privilege user
and returns a session plus a clearance level.</p>

<h2 id="bug-two--the-debug-field-that-shouldnt-exist">Bug two — the debug field that shouldn’t exist</h2>

<p>The login response says MFA is required, and then — inside the same JSON — a <code class="language-plaintext highlighter-rouge">_debug</code> object
carries the <strong>live one-time code in plaintext</strong>. Not a hash, not a masked value: the code you
are supposed to receive by email, handed to anyone who asks.</p>

<p>Debug scaffolding is one of the highest-yield things to look for on any response. Grep every
response you get for <code class="language-plaintext highlighter-rouge">debug</code>, <code class="language-plaintext highlighter-rouge">trace</code>, <code class="language-plaintext highlighter-rouge">internal</code>, <code class="language-plaintext highlighter-rouge">test</code>, <code class="language-plaintext highlighter-rouge">otp</code>, <code class="language-plaintext highlighter-rouge">token</code> — verbose fields leak
the next step of the chain far more often than they leak anything else.</p>

<p>Feeding that code back to the verification route returns a signed session token carrying the
low clearance level.</p>

<h2 id="bug-three--the-token-that-verifies-itself">Bug three — the token that verifies itself</h2>

<p>The privileged endpoint accepts a bearer token and checks the signature — but it lets the
token <em>choose its own algorithm</em>. Switching the header to the “no signature” algorithm means
the verification step is skipped entirely, and the payload is whatever you say it is.</p>

<p>So: take the structure of the valid token you were legitimately issued, rewrite the clearance
claim to the highest level, and set the algorithm to none. No secret was recovered, guessed or
needed.</p>

<p>This is the same failure as “the client decides how it will be authenticated” in any other
form, and it appears in real applications often enough that it’s worth testing on every
token-based endpoint you meet: flip the algorithm, strip the signature, watch whether the
server cares.</p>

<p>It’s worth being precise about <em>why</em> it works, because the mechanism is what generalises.
A signed token is three dot-separated parts: a header declaring how it’s signed, a payload of
claims, and a signature over the first two. Verification normally means “recompute the
signature using the algorithm in the header and compare”. If the header may name the
<strong>no-signature</strong> algorithm, there is nothing to compute and nothing to compare — the server
accepts a token whose third segment is empty. The defender’s own verification code is doing
exactly what it was told; the flaw is that the attacker was allowed to give the instruction.</p>

<p>The other half of the lesson is where the claim lived. “Clearance level” was an ordinary field
inside the payload, trusted after verification. Anything the client can put in a token and the
server reads back as fact is, by definition, client-controlled — unless it is checked against
a store the client cannot write to.</p>

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

<ul>
  <li><strong>Test every input independently.</strong> The injectable parameter is often not the one named
after the thing you’re attacking.</li>
  <li>Debug fields in production responses are a direct roadmap — search for them deliberately.</li>
  <li>Never let the token select its own verification algorithm; pin it server-side and reject
everything else.</li>
  <li>A chain of three “minor” issues is still a full compromise. Severity is a property of the
chain, not of the individual bug.</li>
  <li>Source comments and error messages are documentation written by the defender — read them as
hints.</li>
</ul>]]></content><author><name>ellell</name></author><category term="web" /><category term="sqli" /><category term="jwt" /><category term="authentication" /><summary type="html"><![CDATA[A fictional “detonation portal” where reaching the top clearance level is the whole objective — and three small, independently boring bugs chain into it without a single exploit that would survive a code review. Spoiler-light notes — no flags, credentials or copy-paste exploits. Solve it yourself first.]]></summary></entry><entry><title type="html">NorthKorea</title><link href="https://ellell.gitlab.io/webverse_pro/northkorea/" rel="alternate" type="text/html" title="NorthKorea" /><published>2026-07-18T00:00:00+00:00</published><updated>2026-07-18T00:00:00+00:00</updated><id>https://ellell.gitlab.io/webverse_pro/northkorea</id><content type="html" xml:base="https://ellell.gitlab.io/webverse_pro/northkorea/"><![CDATA[<p>A portal where operatives file mission reports and an automated reviewer endorses them. The
flag isn’t in any database field you can reach — it exists inside the reviewer’s browser for
exactly one moment, and the box is really about capturing that moment.</p>

<p><em>Spoiler-light notes — no flags, credentials or copy-paste exploits. Solve it yourself first.</em></p>

<!-- more -->

<h2 id="recon">Recon</h2>

<p>One port, a reverse proxy in front of an application server. Registering an account exposes
the actual workflow, and the workflow <em>is</em> the target:</p>

<ul>
  <li>Submit a report with a short identifier and a body of text.</li>
  <li>A reviewer bot visits each submitted report and reads it.</li>
  <li>The bot then fills in an endorsement field with its response — <strong>that response contains the
flag</strong> — and clicks the endorse button.</li>
</ul>

<p>That last point is the whole challenge. The flag is never written to anything we can read. It
appears as text in the bot’s browser at the instant it populates that form, and then the bot
navigates away. Meanwhile the bot’s session cookie is <code class="language-plaintext highlighter-rouge">HttpOnly</code>, so the classic
steal-the-cookie move is off the table from the start.</p>

<h2 id="the-injection-point">The injection point</h2>

<p>That identifier is reflected server-side <strong>inside a script block</strong> — the page interpolates it
directly into an object literal in its own inline JavaScript, rather than into markup or into a
quoted string.</p>

<p>The page ships a strict Content Security Policy, and the inline script block carries a nonce.
It’s natural to assume that makes injection safe. It doesn’t, and understanding why is most of
the lesson.</p>

<p><strong>A CSP nonce protects the script <em>block</em> from being injected. It cannot protect code that is
injected <em>within</em> a block the page already trusts.</strong> Because the input lands inside an
object literal rather than in a string context, you can close the literal, run arbitrary
statements, and then reopen a dummy literal so the rest of the original line still parses. The
payload executes with the block’s trust — inline, nonce-blessed, and completely CSP-compliant.</p>

<p>Confirming it is easy: inject something that calls home and watch the reviewer’s request
arrive.</p>

<h2 id="why-the-obvious-approaches-all-fail">Why the obvious approaches all fail</h2>

<p>This is the interesting half of the box. Once you have script execution <em>in the bot</em>, the
naive exfiltration options all die:</p>

<ul>
  <li><strong>Reading <code class="language-plaintext highlighter-rouge">document.cookie</code></strong> — empty. The session cookie is <code class="language-plaintext highlighter-rouge">HttpOnly</code>.</li>
  <li><strong>A timer</strong> — the bot navigates away before it fires.</li>
  <li><strong>Hooking the form’s submit</strong> — the endorsement form is rendered <em>below</em> the injected block,
so at the moment our code runs, that form isn’t in the DOM yet.</li>
  <li><strong>A <code class="language-plaintext highlighter-rouge">fetch()</code> to a same-origin route</strong> — the flag isn’t served by any route; it only exists
in the form the bot is about to fill.</li>
  <li><strong>An iframe</strong> — blocked outright by the <code class="language-plaintext highlighter-rouge">frame-ancestors</code> directive.</li>
</ul>

<p>So the flag isn’t stored, isn’t in a cookie we can read, and isn’t behind a URL. The only place
it exists is the outgoing form submission the bot is about to make.</p>

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

<p>Two observations unlock it:</p>

<ol>
  <li>The form isn’t in the DOM <strong>now</strong>, but it will be parsed a moment later when the browser
finishes rendering the rest of the page. So don’t look for it — listen for it.</li>
  <li>When the bot clicks the button, a submit event fires. Attach a listener on <code class="language-plaintext highlighter-rouge">document</code> in
the <strong>capture phase</strong> (third argument <code class="language-plaintext highlighter-rouge">true</code>). Capture runs <em>before</em> the form’s own handler,
so we read the form’s data at the moment it is fully populated and before navigation begins.</li>
</ol>

<p>At that instant the form data contains the flag the bot just typed. Then it has to get off the
machine — and the bot is about to navigate, which kills ordinary requests. The one API built
for exactly this is <code class="language-plaintext highlighter-rouge">navigator.sendBeacon()</code>: it queues an asynchronous POST that the browser
delivers even as the page unloads. It was designed for analytics on page exit; here it’s the
only reliable channel.</p>

<p>So the payload does three things: register the capture-phase submit listener, serialise the
form data, and beacon it out. Start a listener, submit the poisoned report, and wait for the
bot to review it. The flag arrives in the beacon body.</p>

<h2 id="insights-worth-keeping">Insights worth keeping</h2>

<ul>
  <li><strong>CSP + <code class="language-plaintext highlighter-rouge">HttpOnly</code> does not equal “XSS mitigated”.</strong> The nonce stopped us injecting a new
script block and <code class="language-plaintext highlighter-rouge">HttpOnly</code> stopped cookie theft — but the bot’s <em>actions</em> (what it types,
what it submits) remained fully readable from JavaScript. Exfiltration moved from
exfiltrating secrets-at-rest to exfiltrating secrets-in-transit.</li>
  <li><strong><code class="language-plaintext highlighter-rouge">sendBeacon</code> is the answer whenever the target navigates immediately.</strong> Any timer or
fetch-based exfil loses the race; a beacon doesn’t.</li>
  <li><strong>Capture-phase listeners beat “the element doesn’t exist yet”.</strong> You rarely need the element
to exist to observe an event that will bubble through an ancestor.</li>
</ul>

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

<ul>
  <li>A CSP nonce secures the script tag, not the data inside it — never interpolate user input
into a trusted script block, even “safely” inside a JS object.</li>
  <li><code class="language-plaintext highlighter-rouge">HttpOnly</code> blocks cookie theft, not script execution. Assume anything reachable from the DOM
can be exfiltrated.</li>
  <li>When the target is transient (a value typed into a form, a page that immediately navigates),
observe events rather than polling state.</li>
  <li><code class="language-plaintext highlighter-rouge">navigator.sendBeacon()</code> survives unload — it’s the right tool for exfiltration against
navigation.</li>
  <li>Capture-phase event listeners let you observe a submit before the application’s own handler
sees it.</li>
  <li>If a secret is only ever in a browser’s memory, the exploit is to catch it in flight.</li>
</ul>]]></content><author><name>ellell</name></author><category term="web" /><category term="xss" /><category term="csp-bypass" /><category term="exfiltration" /><category term="bots" /><summary type="html"><![CDATA[A portal where operatives file mission reports and an automated reviewer endorses them. The flag isn’t in any database field you can reach — it exists inside the reviewer’s browser for exactly one moment, and the box is really about capturing that moment. Spoiler-light notes — no flags, credentials or copy-paste exploits. Solve it yourself first.]]></summary></entry><entry><title type="html">Spread</title><link href="https://ellell.gitlab.io/webverse_pro/spread/" rel="alternate" type="text/html" title="Spread" /><published>2026-07-18T00:00:00+00:00</published><updated>2026-07-18T00:00:00+00:00</updated><id>https://ellell.gitlab.io/webverse_pro/spread</id><content type="html" xml:base="https://ellell.gitlab.io/webverse_pro/spread/"><![CDATA[<p>A spreadsheet app with an admin console that is “blocked at the edge” — which turns out to be
a precise description of the whole problem. The guard lives in one tier; the content lives in
another; the two disagree about what a URL is.</p>

<p><em>Spoiler-light notes — no flags, credentials or copy-paste exploits. Solve it yourself first.</em></p>

<!-- more -->

<h2 id="recon">Recon</h2>

<p>Single web port, one vhost, and a small CSV upload / spreadsheet-viewer application. The
navigation contains an item that reads <em>“Admin — internal only (blocked at the edge)”</em>.</p>

<p>That wording is the box handing you the answer’s shape. “Blocked at the edge” tells you three
things at once: there <strong>is</strong> a privileged interface, it exists on this same origin, and the
thing stopping you is a network-layer rule rather than the application’s own authorisation.</p>

<h2 id="reading-the-403-correctly">Reading the 403 correctly</h2>

<p>Requesting the admin path returns a 403 — but the <code class="language-plaintext highlighter-rouge">Server</code> header on that 403 is the edge
proxy, not the application.</p>

<p>That single header is the entire diagnosis. If the application itself were refusing, the
response would come from the app’s own server. A response produced by <em>the thing in front of</em>
the app means the request never reached the app — so the app has never had an opinion about
whether you’re allowed. The access control has been pushed up a tier, and the tier below is
still serving the content.</p>

<h2 id="where-the-two-tiers-disagree">Where the two tiers disagree</h2>

<p>The edge matches the protected path as a <strong>literal string</strong>. The application’s router, like
almost every modern web framework’s, <strong>canonicalises</strong> the request path before routing — it
collapses duplicate separators, decodes percent-encoded characters, and normalises trailing
slashes. Every one of those normalisations turns a path that the edge does not recognise into
the exact path the app does.</p>

<p>So a handful of equivalent spellings of the same path render the admin console, while the one
spelling the edge was told to block is the only one that gets stopped. The bypass doesn’t
require any encoding cleverness beyond knowing that the two tiers normalise differently — the
hard part is recognising that they <em>do</em>.</p>

<h2 id="why-this-generalises">Why this generalises</h2>

<p>The bug isn’t “the regex was incomplete”. It’s <strong>authorisation enforced at the wrong tier</strong>.
Any time a rule is applied in front of the application rather than inside it, the application
is still happy to serve the same content through any other path that reaches it:</p>

<ul>
  <li>a different spelling of the same path,</li>
  <li>a different hostname that routes to the same backend,</li>
  <li>an alternate port or direct-to-origin connection,</li>
  <li>a method the proxy’s rule never considered.</li>
</ul>

<p>Testing these is cheap and almost always decisive, and it belongs <strong>before</strong> reaching for
anything heavier. The proxy is not the application, and “the edge blocks it” is not the same
sentence as “it is protected”.</p>

<p>A practical checklist for any path a front-end tier claims to protect — all of these are
things the edge commonly fails to recognise while the origin happily serves them:</p>

<ul>
  <li>a doubled separator, an omitted separator, or a trailing separator that the rule didn’t
anticipate;</li>
  <li>percent-encoded characters anywhere in a path segment, including the segment separators
themselves;</li>
  <li>case changes, where the app routes case-insensitively and the rule does not;</li>
  <li>the path reached under a different hostname, a different port, or a direct-to-origin
address that bypasses the front tier entirely;</li>
  <li>the same resource addressed by an ID rather than by name.</li>
</ul>

<p>Each is a one-line change to the request, and each either works or rules out the whole
approach in seconds. Working through them is far cheaper than concluding the endpoint is
protected and moving on.</p>

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

<ul>
  <li>A 403 whose <code class="language-plaintext highlighter-rouge">Server</code> header belongs to the proxy means the app never saw the request —
and therefore never applied its own authorisation.</li>
  <li>Enforce authorisation in the application. Edge rules are a second line, never the only one.</li>
  <li>Whenever a path is “blocked”, enumerate equivalent encodings and normalisations before
assuming the block holds: double separators, percent-encoding, encoded trailing separators.</li>
  <li>The same backend is often reachable by more than one name, port or route. Try them.</li>
  <li>Hints in the UI (“internal only”, “blocked at the edge”) describe the architecture — take
them literally.</li>
</ul>]]></content><author><name>ellell</name></author><category term="web" /><category term="access-control" /><category term="path-normalization" /><category term="proxy" /><summary type="html"><![CDATA[A spreadsheet app with an admin console that is “blocked at the edge” — which turns out to be a precise description of the whole problem. The guard lives in one tier; the content lives in another; the two disagree about what a URL is. Spoiler-light notes — no flags, credentials or copy-paste exploits. Solve it yourself first.]]></summary></entry><entry><title type="html">Remit</title><link href="https://ellell.gitlab.io/webverse_pro/remit/" rel="alternate" type="text/html" title="Remit" /><published>2026-07-17T00:00:00+00:00</published><updated>2026-07-17T00:00:00+00:00</updated><id>https://ellell.gitlab.io/webverse_pro/remit</id><content type="html" xml:base="https://ellell.gitlab.io/webverse_pro/remit/"><![CDATA[<p>An accounts-payable portal where two solid bugs fall out quickly — and the final step is
genuinely dead on the installed runtime. Worth writing up <em>because</em> it doesn’t finish.</p>

<p><em>Spoiler-light notes — no flags, credentials or copy-paste exploits. Solve it yourself first.</em></p>

<!-- more -->

<h2 id="recon">Recon</h2>

<p>Two vhosts on one host: a supplier-facing portal and a separate reviewer console. The
supplier signup is open, so the first job is just to register and start poking at the
invoice workflow — which begins with a spreadsheet upload.</p>

<h2 id="bug-one-filter-bypass-by-encoding">Bug one: filter bypass by encoding</h2>

<p>Invoice uploads are parsed server-side: the application pulls an XML part out of the
submitted spreadsheet and parses it with a parser configured to <strong>load a DTD and resolve
external entities</strong>. That’s a classic XML external entity injection, and the only thing
standing in front of it was a <strong>byte-level filter</strong> looking for the ASCII spellings of the
DOCTYPE and ENTITY keywords.</p>

<p>That filter is trivially defeated by encoding. Declaring the document as UTF-16 and writing
it out with a byte-order mark meant the parser auto-detected the encoding and processed the
inline DTD as normal — while the filter, scanning bytes, saw none of the ASCII sequences it
was looking for.</p>

<p>The general lesson outranks the specific payload: <strong>a filter that inspects raw bytes is not
a check on what the parser will actually see.</strong> Any time a defensive control operates at a
different layer from the component doing the work, look for an encoding, normalisation or
representation gap between them.</p>

<p>The result was arbitrary local file read as the web user. Swapping the entity’s URL scheme
for an encoding filter let me pull the application’s own configuration and source without
triggering parse errors — which is the important escalation here. <strong>XXE is an information
disclosure bug, but the information is the source code</strong>, and that changes the next step
entirely.</p>

<h2 id="bug-two-an-allow-list-that-includes-the-wrong-field">Bug two: an allow-list that includes the wrong field</h2>

<p>Reading the profile-update handler from source showed the classic mass-assignment pattern:
the code loops over every submitted field and writes the ones that appear in an allow-list.
The allow-list is the fix — except this one contains <code class="language-plaintext highlighter-rouge">role</code>.</p>

<p>So a supplier could update their own role. One request, and the same credentials now
authenticated against the <strong>reviewer console</strong>, with access to the whole invoice review
queue.</p>

<p>This is worth calling out as a pattern rather than a one-off: allow-lists fail in exactly
one way — they contain one field too many. When you read an update handler, the allow-list
is the first thing to check, and any field that maps to authorisation is an instant finding.</p>

<h2 id="the-blocker-a-gadget-that-the-runtime-killed">The blocker: a gadget that the runtime killed</h2>

<p>The intended finish was remote code execution via a <strong>deserialization gadget chain</strong>, using
an object-destruction method found in the codebase that performed an arbitrary file write.
The delivery mechanism was a URL wrapper that the upload parser would resolve, which would
trigger the destructor.</p>

<p><strong>It does not work on the installed version.</strong> The current major release of the runtime has
removed that wrapper-triggered deserialization path entirely, so the destructor never fires.
I confirmed every delivery route I could think of was dead — no persistent upload path, the
progress-tracking upload feature disabled, log output properly escaped — and the gadget would
only fire on a version several majors older.</p>

<p>So the box ends here: two confirmed bugs, complete source read, reviewer-level access, and a
documented blocker on the final hop. No flag.</p>

<h2 id="dead-ends-worth-recording">Dead ends (worth recording)</h2>

<p>Also ruled out and confirmed negative: SQL injection in the console’s sort/filter parameters
(properly whitelisted), XSS in the queue rows (correctly HTML-escaped), environment recovery
from the web worker’s <code class="language-plaintext highlighter-rouge">/proc</code> entry (the server strips worker environments), <code class="language-plaintext highlighter-rouge">gopher</code> and
<code class="language-plaintext highlighter-rouge">dict</code> schemes through the entity resolver (not supported by the XML library), and a
classic reverse-proxy path-normalisation trick (not vulnerable here).</p>

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

<ul>
  <li><strong>Byte-level filters do not describe what the parser sees.</strong> Encoding, BOMs and
normalisation are the gap between the two — and that gap is the bug.</li>
  <li>XXE’s real prize is usually <strong>source code</strong>, not <code class="language-plaintext highlighter-rouge">/etc/passwd</code>. Read it, then let the code
tell you what the next bug is.</li>
  <li>In any update handler, the allow-list is the finding. A privileged field on the list is
mass assignment.</li>
  <li>Encoding a payload isn’t just for WAFs — it’s how you slip past a control written at the
wrong layer.</li>
  <li><strong>Document the blocker.</strong> A writeup that ends “and here’s the thing that is genuinely
patched in this version, with everything I tried” is more useful than a fabricated ending.</li>
  <li>Version matters. Gadget chains and deserialization vectors die when runtimes change — check
the installed version before investing hours in a payload.</li>
</ul>]]></content><author><name>ellell</name></author><category term="web" /><category term="xxe" /><category term="file-read" /><category term="mass-assignment" /><category term="blocked" /><summary type="html"><![CDATA[An accounts-payable portal where two solid bugs fall out quickly — and the final step is genuinely dead on the installed runtime. Worth writing up because it doesn’t finish. Spoiler-light notes — no flags, credentials or copy-paste exploits. Solve it yourself first.]]></summary></entry><entry><title type="html">JurryHurry</title><link href="https://ellell.gitlab.io/webverse_pro/jurryhurry/" rel="alternate" type="text/html" title="JurryHurry" /><published>2026-06-24T00:00:00+00:00</published><updated>2026-06-24T00:00:00+00:00</updated><id>https://ellell.gitlab.io/webverse_pro/jurryhurry</id><content type="html" xml:base="https://ellell.gitlab.io/webverse_pro/jurryhurry/"><![CDATA[<p>A small law-firm site with a contact form nobody reads — except a clerk, every morning, who
turns out to be a headless browser holding a logged-in session.</p>

<p><em>Spoiler-light notes — no flags, credentials or copy-paste exploits. Solve it yourself first.</em></p>

<!-- more -->

<h2 id="recon">Recon</h2>

<p>A single port is open. ICMP is filtered, so the scan needs to skip host discovery. The web
root answers with a redirect to a virtual hostname, which means the interesting content is
name-based, not path-based — add the vhost locally and the site resolves properly.</p>

<p>Fingerprinting gives a reverse proxy in front of an application server, and a session cookie
that is the obvious prize. Enumerating the pages finds the usual small-business set — a home
page, a staff directory, a FAQ, a contact form, and a staff portal that redirects to a login.</p>

<p><strong>The FAQ is the actual finding.</strong> One line says a clerk reviews new enquiries from the
contact form every morning. That single sentence reframes everything: user-submitted input is
going to be rendered in front of an <em>authenticated browser</em>, automatically, on a schedule.
If that rendering is unsafe, we don’t need to attack the login at all — we can attack the
reviewer.</p>

<h2 id="what-didnt-work">What didn’t work</h2>

<p>Worth recording, because it’s where most of the time went:</p>

<ul>
  <li><strong>Common credentials</strong> on the staff login — all rejected identically.</li>
  <li><strong>NoSQL-style operator injection</strong> in the login body — the application refused the JSON
shape outright, and the bracket-notation form failed too. The body parser clearly isn’t
configured for nested objects.</li>
  <li><strong>Classic SQL injection</strong> — no change in response size or content.</li>
  <li><strong>Reflected template injection</strong> through the contact form — several template syntaxes were
tried, and none appeared in the response. That’s the clue: the input isn’t reflected <em>back
to us</em>, it’s stored for someone else. Re-testing for reflected output was the wrong test.</li>
</ul>

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

<p>The contact form’s message field is stored and later rendered as raw markup on the internal
review page, with no output encoding. That makes it <strong>stored cross-site scripting</strong>, and
because the reviewer is an automated browser rather than a person, it needs no social
engineering or user interaction to fire.</p>

<p>Two design decisions turn a scripting bug into a full account takeover:</p>

<ol>
  <li>The reviewer’s session cookie <strong>isn’t <code class="language-plaintext highlighter-rouge">HttpOnly</code></strong>, so the page’s own JavaScript can read it.</li>
  <li>The reviewer’s session is a normal, long-lived authenticated session with access to the
staff area — not a scoped, short-lived service identity.</li>
</ol>

<h2 id="exploitation">Exploitation</h2>

<p>The whole chain is four steps:</p>

<ol>
  <li>Stand up an HTTP listener on the attack host.</li>
  <li>Submit a contact message whose body is a script tag that ships the reader’s document cookie
(and current URL) to that listener.</li>
  <li>Wait for the reviewer to process the queue. The script executes <strong>in the reviewer’s
browser context</strong>, so the cookie that arrives is <em>its</em> cookie, not ours. It also reveals the
internal address of the staff area, which is only reachable from inside the host.</li>
  <li>Replay the captured cookie against the staff portal through the proxy. The session is
accepted and the protected page renders — including the flag.</li>
</ol>

<p>No password was guessed and no vulnerability in the login itself was needed. We borrowed a
session by making the application render our input for someone else.</p>

<h2 id="why-it-worked">Why it worked</h2>

<ul>
  <li><strong>Stored, not reflected.</strong> Blind XSS is easy to conclude “not vulnerable” on if you’re only
looking at your own response. The signal here was a form with <em>no visible output at all</em> —
that’s exactly when to reach for the stored class.</li>
  <li><strong>Unencoded output in an internal view.</strong> The review page was treated as trusted because
“only the clerk sees it”. That’s backwards: the clerk is the highest-privileged reader.</li>
  <li><strong>Cookie scope.</strong> Missing <code class="language-plaintext highlighter-rouge">HttpOnly</code> is what converted “I can run script as an admin” into
“I have the admin’s session”.</li>
  <li><strong>Automation improves reliability.</strong> A human might not click the link. A bot runs every
morning, on every submission, forever.</li>
</ul>

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

<ul>
  <li>A FAQ or marketing line describing an internal process is reconnaissance gold — “a clerk
reviews this daily” is a statement about an automated consumer of your input.</li>
  <li>When a form produces no visible output, test for <strong>stored</strong> injection, not reflected.</li>
  <li>Stored XSS against a bot is a session-theft primitive, not a popup.</li>
  <li><code class="language-plaintext highlighter-rouge">HttpOnly</code> on session cookies is the control that breaks the exfiltration step even when the
XSS exists.</li>
  <li>Automated reviewers should run sandboxed with minimal, short-lived credentials — never with a
long-lived authenticated session.</li>
  <li>A restrictive Content Security Policy is worth having, but the exfiltration here happened
through a plain image/URL request, so treat CSP as one layer, not the answer.</li>
</ul>]]></content><author><name>ellell</name></author><category term="web" /><category term="xss" /><category term="session-hijacking" /><category term="bots" /><summary type="html"><![CDATA[A small law-firm site with a contact form nobody reads — except a clerk, every morning, who turns out to be a headless browser holding a logged-in session. Spoiler-light notes — no flags, credentials or copy-paste exploits. Solve it yourself first.]]></summary></entry></feed>