Read time:00:11
Release date:12.4.2025
Product: Directus
Affected versions: 10.8.0 to before 11.9.3
Fixed versions: 11.9.3
Severity: Critical
CVE numbers: CVE-2025-55746
Authors: Zombor Máté (PCA)
Product Description
Directus is a web CMS, and more accurately a real-time API and App dashboard for managing SQL database content, with the capability to manage files. The project boasts about 33k stars on github with an active community of developers, users and plugin creators, and It is widely used by thousands of websites world-wide, mostly for handling custom assets and serving files, such as images.
With Directus being an open-source project, the PCA Cybersecurity team performed analysis of the source code and tested the vulnerability in a self-contained test instance.
Summary
During a penetration test, PCA Security Researchers discovered a vulnerability in the file update mechanism which allows an unauthenticated actor to modify existing files with arbitrary contents (without changes being applied to the files' database-resident metadata) and / or upload new files, with arbitrary content and extensions, which won't show up in the Directus UI.
The vulnerability is tracked under the CVE ID of CVE-2025-55746 and existed in Directus from versions 10.8.0 to before 11.9.3.
| CVE ID | Description | CVSS 3.1 score |
|---|---|---|
Unauthenticated file upload and file modification due to lacking input sanitization | 9.3 (Critical) |
Disclosure timeline
| Date | Description |
|---|---|
2025.07.02 | Initial report on Github |
2025.07.09 | Version 1.9.3 releases with a fix |
2025.08.20 | Directus makes advisory public on Github |
2025.12.04 | Public write up on PCA website |
Details
The vulnerability resides in the file update mechanism of Directus. Like other Node-made web applications, Directus follows the well-known request processing pattern of:
URL Prefix -> Controller -> Endpoint Handler -> Service
Controllers are associated with a URL prefix and contain separate handlers for specific endpoints, which utilize Service classes that wrap around sensitive assets and operations. The FileService for instance would serve dual purposes. On one hand, it allows for handing file metadata in the database, such as the file name, the file name on disk, the size, or the MIME type. On the other, it helps with creating the actual files on the file system.
Directus exposes the CRUD operations for uploading or handling files under the /files URL prefix. From the perspective of the vulnerability, the following functions are relevant: (Both can be found in the api/src/controllers/files.ts file.)
PATCHendpoint handler for the/:pkpath- The
multipartHandlermethod
The endpoint handler is responsible for updating an existing file identified by the provided primary key specified through the pk parameter. Primary keys are UUID values such as /files/927b3abf-fb4b-4c66-bdaa-eb7dc48a51cb. When the UUID is provided through the pk parameter, the corresponding file will be updated.
As a side note, It is not difficult to acquire valid UUID values as websites loading images from a Directus instance will reference them using
/assets/<UUID>links. These will naturally contain valid UUID values that can be used for exploitation.
Upon receiving a request, the multipartHandler function is inserted into the request processing pipeline as shown below:
router.patch(
'/:pk',
asyncHandler(multipartHandler), // multipart form handling happens first
asyncHandler(async (req, res, next) => {
The multipartHandler function is responsible for the preliminary parsing of request data in requests with a multipart/form-data content type:
export const multipartHandler: RequestHandler = (req, res, next) => {
...
const busboy = Busboy({ // Instantiating Busboy to parse the file
...
});
... // Busboy parses individual fields
busboy.on('field', (fieldname, val) => {
...
payload[fieldname] = fieldValue; // 1
});
... // Busboy parses a file
busboy.on('file', async (_fieldname, fileStream: BusboyFileStream, { filename, mimeType }) => {
...
const allowedPatterns = toArray(env['FILES_MIME_TYPE_ALLOW_LIST'] as string | string[]); // 2
const mimeTypeAllowed = allowedPatterns.some((pattern) => minimatch(mimeType, pattern));
...
if (mimeTypeAllowed === false) { // 3
return busboy.emit('error', new InvalidPayloadError({ reason: `File is of invalid content type` }));
}
...
payload.filename_download ||= filename; // 4
...
try { // The uploadOne method of the FileService is called
const primaryKey = await service.uploadOne(fileStream, payloadWithRequiredFields, existingPrimaryKey);
...
} ...
...
};
A brief explanation of the important steps:
- Busboy is used to construct a payload by setting arbitrary fields of the payload based on the incoming request.
- The default settings allow for a
*/*mime type in thepackages/env/src/constants/defaults.tsfile. - Creating a file with a type that's not allowed (everything's allowed by default) is not possible. (This will be important later.)
- The filename is user-controlled without any kind of sanitization being done on it.
The next function to check is the uploadOne method from the api/src/services/files.ts file. It is used to upload a single file to the storage adapter and create the database representation of it (in case It's a new file).
async uploadOne(
...
if (primaryKey !== undefined) { // Load metadata from an existing file if a PK was specified
existingFile =
(await this.knex
....
}
...
const payload = { ...(existingFile ?? {}), ...clone(data) }; // 1
...
// Marking the file as replacement if a primary key was defined in the request
const isReplacement = existingFile !== null && primaryKey !== undefined;
...
if (isReplacement === false || primaryKey === undefined) { // 2
primaryKey = await this.createOne(payload, { emitEvents: false });
}
...
const fileExtension = // 3
path.extname(payload.filename_download!) || (payload.type && '.' + extension(payload.type)) || '';
...
payload.filename_disk ||= primaryKey + (fileExtension || ''); // 3
...
if (isReplacement === true && path.extname(payload.filename_disk!) !== fileExtension) { // 3
payload.filename_disk = primaryKey + (fileExtension || '');
}
...
const tempFilenameDisk = 'temp_' + payload.filename_disk; // 4
...
try {
...
if (isReplacement === true) {
await disk.write(tempFilenameDisk, stream, payload.type); // 5
} ...
}
...
if (isReplacement === true) {
await this.updateOne(primaryKey, payload, { emitEvents: false }); // 6
...
}
...
}
The explanation of the marked code locations:
- The request data acquired by parsing the multipart form from step
1in the previous code snippet is merged into the file's metadata- This allows for overriding parts of the existing file's metadata which will be useful later
- If a PK value is not specified, the
createOnemethod will throw an error here due to the lack of authentication, which would halt execution (and exploitation)- This is why the PK value is needed. Such values, however, can easily be acquired
- Steps
3are all about setting the file name- The
fileExtensionvalue is based on thefilename_downloadvariable, which is user-controlled - Even if the
extnamecall cannot determine a mime type for the file, the second check won't fail as a mime type HAS to be defined according to step2of the previous listing, this prevents the creation of files without extensions - The
filename_diskis once again based on the user-controlledpayloadvariable - Finally, the
filename_diskandfilename_downloadfields must result in the same extension or the file name will be replaced by the file's UUID
- The
- An unfiltered
filename_diskvalue is concatenated to thetemp_prefix to produce a temporary filename, makingpath traversalspossible - The temp file is saved to the disk
- The
updateOnecall results in an error, ensuring the previously created file remains on the disk
Notice, that only in the last step does authentication kick in. Before it, everything else happens in an unauthenticated context. While in step 2 there's a brief possibility of authentication halting the exploitation process by making the createOne function fail, by specifying a PK value it is possible to circumvent this check and have the server continue execution.
To help track the flow of the user-supplied, malicious data, the following diagram shows Its path:
Since the filename_disk value is never sanitized, It's possible to pass any value containing traversal sequences (../) through it, but a fully arbitrary file write is not possible in case the "local" storage handler is used. (Other storage implementations haven't been checked during the research process).
As an explanation, the packages/storage-driver-local/src/index.ts file defines the two relevant functions: write and fullpath. Their implementation can be seen below:
function fullPath(filepath: string) {
return join(this.root, join(sep, filepath));
}
async write(filepath: string, content: Readable) {
const fullPath = this.fullPath(filepath);
await this.ensureDir(dirname(fullPath));
const writeStream = createWriteStream(fullPath);
await pipeline(content, writeStream);
}
The write method uses the fullPath method to create the absolute path for the to-be-created file. The two join calls assemble the final path. As the fullPath relies on join to create a relative path starting with the separator to be added under the download directory, this first call normalizes the path and further traversal steps won't be possible. However, It is possible to make the system "ignore" the temp_ prefix given to the file. In fact, any path under the download directory can be set, resulting in an arbitrarily named file being placed in the upload folder.
As a summary for the vulnerability:
- The contents of an existing file can be changed, as an existing UUID can be specified as the file name
- The metadata won't change, so the mime type cannot be modified
- This also makes the changes happen "silently", without Directus "knowing" about the changes
- A new, previously non-existent file can be created with arbitrary contents
- The file won't show up on the Directus UI, it can only be seen through other means (such as shell access)
- An extension MUST be defined for the file to be modified
- This prevents uploading executables or malware with no extensions
As SVG images are frequently uploaded to Directus instances, the vulnerability would allow an attacker to deploy sophisticated Javascript payloads embedded into SVG images, but these attempts are likely to be thwarted due to the default-src: none Content Security Policy header sent by Directus when serving images. This makes complex attacks very difficult to pull off.
Lack of authentication
Outside of the data flow, the vulnerability demonstrates a separate issue. Namely, the approach to creating and handling authentication data. Directus creates authentication data upon receiving requests by parsing the received Authorization header values and associating these with users. However, these values are only checked way down either through manual checks or when calls are made to the database.
Recall how Directus is meant to allow for easy management of SQL databases. This might be the reason why authentication is so closely tied to the database. When a query is executed, a special context is set up which allows for "built-in" or "automatic" access rights checks. This approach, however means everything before this step is essentially unauthenticated.
To demonstrate how the normal authentication flow is bypassed, the following diagrams shows where Directus creates the authentication data and with where It's checked:
One can see how checks happen far from where the actual operations - and the vulnerability itself - take place, providing a chance to perform potential sensitive operations before authentication could stop malicious actions.
Exploitation prerequisites
As providing a primary key is required for successful exploitation, at least one file with a known UUID must be available for an attacker. This can usually be achieved by browsing an application that relies on Directus to provide images.
Naturally, the instance needs to be accessible over the network used by the attacker as well.
Once network access and knowledge of at least one file UUID is available for the attacker, exploitation can be done by sending a single request.
Potential impacts
The impact of successful exploitation is highly dependent on how Directus is set up to be used by other applications. Many different configurations can be created, but the following are likely the most noteworthy.
Poisoning hosted files
Let's consider the following scenario: Directus is used not only to serve contents on a company's web page, but internally as well. On-boarding documents for newcomers are hosted on the instance. Manuals with links to internal services are provided through PDF files. If the file can be accessed and modified by an attacker, it would be trivial to set up spoofed instances of real, internal services and place malicious links to them in the official documents. This could allow for stealing credentials given to new hires.
Setting up a phishing site
As mentioned previously, SVG files are often uploaded to Directus. SVGs can be used to set up sophisticated looking pages, as it allows the embedding of HTML, CSS and scripts. For example, It is possible to set up a pseudo-login page by replacing one of the SVG files with one that has an HTML payload in it, that contains a "login form". The issue is once again with the default-src: none CSP settings. This setting prevents the use of CSS in the SVG file, so the created page will look strange. Take the following for instance:
The error message at the top can be used to make it look like an error when loading the page.
While the page obviously looks strange, it's important to remember since the domain will check out, the browser could fill out the login form, making for a much more convincing page as shown below:
(Unlikely scenario) Server serves files directly from the upload directory
In this setup, a server such as Nginx serves files in a static manner. The files are directly served from a "public" folder made accessible through Directus.
+-----------+ +-----------+ +----------------+
| Frontend | --------> | Server | | CMS |
+-----------+ +-----------+ +----------------+
\ /
\ /
+----------------+
| File Storage |
+----------------+
Since the files loaded by the server are sourced directly from the file storage, the arbitrary file write might allow an attacker to upload a webshell into the folder, giving it an arbitrary file extension. As the extension checks out as a valid PHP file for instance, and the contents are correct code, an attacker could achieve unauthenticated code execution on the server.
Although not impossible, It must be stressed how UNLIKELY this scenario is. Regardless, It is recommended to review your infrastructure due to the potential impact.
Latest Advisories
Popular tags
security advisory
skoda
pcautomotive
vw
mib3 infotainment unit
preh car connect gmbh
infotainment unit vulnerability
vehicle cybersecurity
nissan
vehicle penetration testing
Authors
Máté Zombor
Security Researcher
Máté is a Security Researcher at PCA Cyber Security with over six years of experience in offensive security. With a background in penetration testing, he focuses on vulnerability research, exploit development, reverse engineering, and code analysis. His work spans web, mobile, and embedded systems, driven by a passion for uncovering complex security flaws and advancing technical research.
