CVE-2025-55746 - Unauthenticated File Modification in Directus CMS

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 IDDescriptionCVSS 3.1 score

Unauthenticated file upload and file modification due to lacking input sanitization

9.3 (Critical)

Disclosure timeline

DateDescription

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.)

  • PATCH endpoint handler for the /:pk path
  • The multipartHandler method

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:

  1. Busboy is used to construct a payload by setting arbitrary fields of the payload based on the incoming request.
  2. The default settings allow for a */* mime type in the packages/env/src/constants/defaults.ts file.
  3. Creating a file with a type that's not allowed (everything's allowed by default) is not possible. (This will be important later.)
  4. 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:

  1. The request data acquired by parsing the multipart form from step 1 in 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
  2. If a PK value is not specified, the createOne method 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
  3. Steps 3 are all about setting the file name
    • The fileExtension value is based on the filename_download variable, which is user-controlled
    • Even if the extname call cannot determine a mime type for the file, the second check won't fail as a mime type HAS to be defined according to step 2 of the previous listing, this prevents the creation of files without extensions
    • The filename_disk is once again based on the user-controlled payload variable
    • Finally, the filename_disk and filename_download fields must result in the same extension or the file name will be replaced by the file's UUID
  4. An unfiltered filename_disk value is concatenated to the temp_ prefix to produce a temporary filename, making path traversals possible
  5. The temp file is saved to the disk
  6. The updateOne call 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:

Malicious data flow

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:

Auth sequence diagram

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:

Phishing page made entirely in SVG

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:

Phishing page made entirely in SVG

(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.

Popular tags

security advisory

skoda

pcautomotive

vw

mib3 infotainment unit

preh car connect gmbh

infotainment unit vulnerability

vehicle cybersecurity

nissan

vehicle penetration testing

Authors

Mate

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.