Summary
Pimcore Vulnerable to Remote Code Execution via DataObject Class-Definition Field Name
Advisory details
Overview
A DataObject class-definition field name is concatenated, without an identifier allowlist, into the PHP class source that Pimcore generates for every DataObject class (protected
lt;fieldName>;). A user holding only the ordinary objects (DataObjects) permission can import a class definition whose field name closes the property and injects arbitrary PHP into the generated class file, achieving remote code execution on the server. The same unvalidated field name is also concatenated into ALTER TABLE DDL (ADD COLUMN/ADD INDEX), giving a parallel SQL-injection primitive. This is a sibling of CVE-2026-5394 (composite-index column SQL injection); that fix hardened only the compositeIndices sink and left the field-name path untouched.
Impact
Any authenticated user with the objects permission — the standard permission for content editors who work with DataObjects, not an administrator or a dedicated "classes" permission — can:
- Execute arbitrary PHP on the server (RCE). The injected code runs in the web application's PHP process when an object of the affected class is loaded (and is re-executed on every load), with full access to the application, its database credentials, secrets, and the host filesystem/OS — i.e. full server compromise.
- Execute arbitrary ALTER TABLE DDL (SQL injection) against the DataObject store/query tables (drop columns, add indexes, corrupt schema).
Confidence (read with the Reproduction section). The RCE sink — the real builder emitting attacker PHP into the generated class body, that class loading, and its
__construct()executing a shell command — is runtime-confirmed in an isolated harness (see Reproduction → "Lab confirmation"). The remaining links of the end-to-end chain are reasoned from source but not yet run end-to-end on a live Pimcore: (a) the Studio import path (generateLayoutTreeFromArray→save) preserving the field name without transform/reject; (b) the persistent-field DDL step not aborting the save (addressed by the ≤64-byte gadget); and (c) Pimcore instantiating the object (new, e.g. viaDataObject::getById()) so__construct()fires — autoloading alone executes only top-level class-body code, not the constructor. Treat the RCE as sink-confirmed + chain-reasoned, not as a fully-executed live exploit.
Because the injected PHP executes with the privileges of the PHP runtime (typically the web-server user) and reaches the operating system — beyond the authority of the Pimcore application account the attacker started from — the scope is assessed Changed (S:C), consistent with Pimcore's own scoring of the analogous Custom-Reports SQL injection (GHSA-3234-gxc3-pq6f, AV:N/AC:L/PR:L/UI:R/S:C, 8.7); the result here is RCE rather than read-only SQLi, yielding 9.9 Critical. S:C is the one debatable metric: a reviewer who scores the impact within the single PHP/OS authority as S:U lands at AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H = 8.8 High. The severity floor is therefore High regardless of the scope interpretation.
Technical Details
Source → sink (RCE)
Pimcore generates a PHP class file for every DataObject class. The property block is built in lib/DataObject/ClassBuilder/FieldDefinitionPropertiesBuilder.php:
php // lib/DataObject/ClassBuilder/FieldDefinitionPropertiesBuilder.php:27-32 foreach ($classDefinition->getFieldDefinitions() as $key => $def) { if (!$def instanceof ClassDefinition\Data\ReverseObjectRelation && !$def instanceof ClassDefinition\Data\CalculatedValue) { $cd .= 'protected
#39;.$key.";\n"; // $key = field NAME, concatenated raw into PHP source } }
$key is the field name. The string is assembled into a class body in lib/DataObject/ClassBuilder/ClassBuilder.php:104-112 (class <Name> extends <...> {\n + properties), written to var/classes/DataObject/<Class>.php, and autoloaded/included. A field name such as:
poc; public function __construct(){ /* attacker PHP */ } private $z
produces a valid class body containing an attacker-defined __construct() that executes when an object of the class is loaded.
That the maintainers know name→PHP-generation requires an identifier allowlist is shown by the sibling enum-option generator, which does enforce one:
php // lib/DataObject/ClassBuilder/SelectOptionsEnumBuilder.php:188 if (!preg_match('/^[A-Z-a-z_][A-Za-z0-9_]*$/', $selectOptionName)) { /* reject */ }
The field-name path has no equivalent.
Parallel SQL-injection sink
The same field name is concatenated, with backtick string quoting (not quoteIdentifier), into DDL:
php // models/DataObject/ClassDefinition/Helper/Dao.php:102 (addModifyColumn — ADD COLUMN) $this->db->executeQuery('ALTER TABLE ' . $table . ' ADD COLUMN ' . $colName . ' ' . $type . ...); // :52/:67 (addIndexToField — ADD INDEX <prefix><name> (<name>)) $this->db->executeQuery('ALTER TABLE ' . $table . ' ADD ' . $uniqueStr . 'INDEX ' . $prefix . $indexName . ' (' . $columnName . ');');
Source: models/DataObject/ClassDefinition/Dao.php:228 → $this->addModifyColumn($objectDatastoreTable, $key, $value->getColumnType(), '', 'NULL'), $key = field name. A backtick in the field name breaks out of the quoted identifier.
Contrast the patched composite-index sink, now guarded by an allowlist and quoteIdentifier (models/DataObject/Traits/CompositeIndexTrait.php).
Why validation does not stop it
The complete field-name validation across the import → save path:
models/DataObject/ClassDefinition/Service.php:296(generateLayoutTreeFromArray):preg_match('/<.+?>/', $name)— rejects only angle-bracket names. Backtick,;,{},(), quotes, spaces all pass.models/DataObject/ClassDefinition/Data.php:1292(isForbiddenName()) — a reserved-word denylist (in_array(strtolower($name), FORBIDDEN_NAMES)), no character filtering.
models/DataObject/ClassDefinition.php:1149— validates the class name/id only. No allowlist is applied to field names. The Studio UI enforces an identifier pattern client-side; the API does not.
Reachability / privilege
The HTTP entry point (pimcore/studio-backend-bundle):
#[Route(self::ROUTE, name: 'pimcore_studio_api_class_definition_import', methods: ['POST'])] #[IsGranted(UserPermissions::DATA_OBJECTS->value)] // UserPermissions::DATA_OBJECTS = 'objects' public function importClassDefinition(string $id, #[MapUploadedFile] UploadedFile $file): JsonResponse { return $this->jsonResponse( $this->classDefinitionService->importClassDefinitionFromJson($id, $file->getContent()) ); } ```
`importClassDefinitionFromJson` → `ClassDefinitionRepository::importFromJson` → model `save()` → `saveClassInternal()`, which runs the DDL (`getDao()->save()`) and then the PHP class generation (`generateClassFilesInternal()`). The endpoint requires only the `objects` permission (`PR:L`) and performs no field-name validation of its own. The single authorization gate is the route-level `#[IsGranted('objects')]`; a DataObject **class definition** is global schema (not a workspace-scoped element), so no element-/workspace-level secondary authorization applies to the import — `objects` alone reaches the sink, which is what anchors `PR:L`. This is the same import endpoint used in the CVE-2026-5394 PoC.
### Execution order
In `saveClassInternal()`: field denylist check → class-name regex → `getDao()->save()` (DDL sink fires) → `generateClassFilesInternal()` (PHP-gen sink fires). The SQLi triggers first; the RCE payload either uses a non-persistent field type (no `ADD COLUMN`) or a ≤64-byte DDL-valid name so the DDL step does not abort before PHP generation.
## Reproduction
### Lab confirmation of the RCE sink (runtime, verified)
Using the **unmodified** `FieldDefinitionPropertiesBui
References
- https://github.com/advisories/GHSA-9x44-4gxf-8c25
- https://github.com/pimcore/pimcore/security/advisories/GHSA-9x44-4gxf-8c25
- https://github.com/pimcore/pimcore/pull/19183
- https://github.com/pimcore/pimcore/commit/a4f8c3cfee58b7d5fe4873d67782eff58dae9b9d
- https://github.com/pimcore/pimcore/releases/tag/v2026.1.6
Related vulnerabilities
All Supply chain →- HIGHCVE-2026-75911
CodeWhale: Project config `allow_shell` override enables arbitrary shell command execution via cloned repository
- HIGHCVE-2026-75858
CodeWhale: rlm_eval auto-approves arbitrary Python execution, bypassing the user's approval policy (RCE)
- HIGHCVE-2026-72807
SiYuan: Second-order SSTI to arbitrary SQL via attribute-view template column (queryBlocks): malicious imported package executes SQL on victim kernel
- CRITICALCVE-2026-72811
SiYuan: SQL injection in backlink/mention search via unescaped stored and client input (publish mode): first-order (client keyword) and second-order (stored document title) breakout on read-write handle
- CRITICALCVE-2026-62681
Orval: RCE via OpenAPI path -> unescaped request-URL template literal (backtick breakout)
- CRITICALCVE-2026-62682
Orval: RCE via servers[].url -> unescaped request-URL template literal (with getBaseUrlFromSpecification)