Command Injection in DumbDrop: CVE-2025-24971

A filename should tell an application what to call a file. It should not tell the operating system what command to execute.

Unfortunately, applications occasionally mix user-controlled data with system commands and then ask a shell to interpret the result. At that point, the filename stops being metadata and starts auditioning for a terminal session.

This is the core of OS Command Injection: data that should remain harmless becomes part of an executable command.

What Is OS Command Injection?

OS Command Injection happens when an application uses externally controlled input to construct an operating system command without safely separating data from executable instructions.

Consider a web application that needs to process an uploaded image:

exec(`convert ${filename} output.png`);

The intended input might be:

profile.jpg

But if the application passes the value through a shell, special characters may change how the command is interpreted.

The vulnerability is not simply that the application accepts unusual characters. The dangerous combination is:

User-controlled input -> Command string construction -> Shell interpretation -> Operating system command execution

Command Injection is different from uploading a malicious executable. The attacker does not necessarily need to upload or run a separate program. The application already provides access to a command interpreter through its own backend logic.

How User Input Reaches the Shell

Command Injection usually begins inside a legitimate feature.

Applications commonly interact with operating system utilities to:

  • convert images;
  • compress or extract archives;
  • query network information;
  • process media files;
  • send notifications;
  • create backups;
  • run diagnostic tools.

The problem appears when developers build the command as one large string:

const command = `program --message "${userInput}"`;
exec(command, { shell: true });

Even though the input appears between quotes, the shell may still interpret constructions such as:

$(command)

On Unix-like systems, $() means: execute the command inside the parentheses and replace it with its output.

Escaping for JSON, HTML or JavaScript does not automatically make a value safe for a shell. Each interpreter has its own syntax, because apparently one collection of dangerous special characters was not enough.

Impact

A successful Command Injection gives the attacker the ability to execute commands with the permissions of the vulnerable application.

Depending on that execution context, an attacker may be able to:

  • read application files and environment variables;
  • modify or delete stored data;
  • access credentials available to the process;
  • install additional tools or persistence;
  • disrupt the service;
  • interact with internal systems reachable from the server.

When the application runs inside a container, the command initially executes inside that container. This limits direct access to the host, but it does not make the issue harmless. Containers may still contain secrets, application data, mounted volumes and network access to other services.

The real impact is therefore determined by what the compromised process can access.

CVE-2025-24971 and DumbDrop

CVE-2025-24971 is a critical OS Command Injection vulnerability reported in DumbDrop, an open-source web application for uploading files.

The vulnerable flow uses the uploaded filename inside an Apprise notification. When notifications are enabled, the application creates a command containing the notification message and executes it through the operating system shell.

The advisory identifies the filename as the attacker-controlled parameter and assigns the vulnerability a CVSS 4.0 score of 9.5. Exploitation requires Apprise notifications to be enabled, but does not require prior authentication or user interaction in an exposed configuration. (GitHub)

The advisory refers to POST /upload/init. In the refactored build used by the controlled lab, the upload router is mounted under /api/upload, making the complete request path:

POST /api/upload/init

The filename still reaches the real upload and notification flow.

Demonstrating the Vulnerability

The vulnerability was reproduced in a local Docker environment using a safe payload:

poc-$(id>/tmp/command-injection-proof.txt).txt

The request initializes a zero-byte upload using the malicious filename. When the application prepares the Apprise notification, the shell interprets $() and executes:

id

Instead of opening a reverse shell or changing application data, the command writes its output to a temporary proof file inside the container.

The application responds normally with an uploadId, while the proof file contains:

uid=0(root) gid=0(root) groups=0(root),...

This confirms that data supplied through the web request reached an operating system execution sink and ran under the container’s root user.

The historical image used in the lab contains the vulnerable shell-execution code, but a later configuration refactor introduced different property names between the configuration object and notification service. A small compatibility adjustment mapped those existing properties so the original notification path could be reached.

That adjustment did not add the vulnerable command construction, change the filename handling or create a new execution sink. However, it means the container was not completely untouched. The lab reproduces the real endpoint, input flow, notification function and vulnerable shell behavior, with that limitation documented.

Root Cause

The vulnerable service processes the filename, inserts it into the notification message and builds a textual command:

const message = APPRISE_MESSAGE
.replace('{filename}', sanitizedFilename)
.replace('{size}', formattedSize)
.replace('{storage}', totalStorage);
const command = `apprise ${APPRISE_URL} -b "${message}"`;
await execAsync(command, { shell: true });

The filename is passed through JSON.stringify(), but that operation escapes JSON syntax, not shell syntax. The $() construction remains meaningful when the final string is interpreted by the shell.

Mitigation

The structural fix is to stop combining untrusted data with shell command strings.

Instead of this:

exec(`apprise ${url} -b "${message}"`, { shell: true });

Use an API such as execFile() or spawn() and pass each argument separately:

execFile("apprise", [url, "-b", message]);

With no shell interpreting the command, characters such as $, ; and | remain part of the argument instead of becoming executable syntax.

The project’s security patch followed the same defensive direction: sanitize the filename, pass command arguments separately and disable shell execution. (GitHub)

Additional controls should include:

  • updating DumbDrop to a revision containing the security fix;
  • restricting filename length and allowed characters;
  • running the application as a non-root user;
  • limiting container access to secrets and internal networks;
  • avoiding unnecessary host-mounted directories;
  • testing every feature that passes user input to external programs.

Input validation reduces exposure, but it should not be the primary barrier. The real fix is removing the shell from the data path.

Conclusion

CVE-2025-24971 demonstrates why Command Injection often hides inside ordinary features rather than obviously dangerous ones.

The application was not offering a terminal. It was sending a file-upload notification. But because the filename was inserted into a textual command and executed through a shell, the notification feature became an operating system interface.

A filename is supposed to identify a file. When it starts returning uid=0, the application has probably delegated a little too much responsibility to it.

Discover more from VSec

Subscribe now to keep reading and get access to the full archive.

Continue reading