Self Host Saturday

The Nextcloud Backdoor That Shipped as a Tidy-Up Commit

CVE-2026-110447 is a pre-auth command injection in Nextcloud's built-in Collabora CODE server, exploited since September and now in CISA KEV. The fix shipped a month earlier in a commit titled "tidy up how the coolwsd launch command is built," with no advisory. Disabling the app does not close it.

a rack of servers in a server room
Photo by Kevin Ache / Unsplash

There is a specific kind of bad week where the patch for the thing attacking you has been sitting in your package manager for a month, and nobody told you it was a patch.

That is the shape of CVE-2026-110447. It's a pre-authentication OS command injection in the Collabora Online Built-in CODE Server app for Nextcloud — richdocumentscode, the one-click option in the app store that gives you Office documents in the browser without standing up a separate Collabora container. MITRE scored it 9.2 under CVSS 4.0. Attackers were using it in the wild in September. CISA added it to the Known Exploited Vulnerabilities catalog on 23 October and gave federal agencies until 26 October to deal with it, which is three days.

The fix shipped on 11 September, in a release whose changelog mentions the AppImage version and nothing else.

This is Self Hosting Saturday, and this one is worth the whole slot, because almost everything about how it played out is instructive and only some of it is Collabora's fault.

What You Need To Do, Before Any of the Explanation

If you run Nextcloud with the built-in CODE server, in order:

  1. Update richdocumentscode to 26.4.502, or at absolute minimum 26.4.303. If you are on Nextcloud 29 or older you cannot install either — 26.4.303 requires Nextcloud 30+ — so your options are upgrading Nextcloud or removing the app.
  2. Do not just disable the app. Collabora's advisory is unusually blunt about this: "It is not sufficient to disable the app to avoid the problem, it must be upgraded to >= 26.4.303 or removed." The reason is in the next section and it is worth understanding rather than just obeying.
  3. Assume you may already be compromised and go looking. Patching does not remove a web shell that is already on disk.

Everything after this is why.

Why Disabling It Does Nothing

The entry point is a file called proxy.php, and it is not a Nextcloud route. It's a standalone PHP script sitting in the app directory, served directly by your web server. I pulled the repository and read it, and the thing that jumps out is what's absent: there is no require of Nextcloud's lib/base.php, no session handling, no capability check, no user lookup. Nothing in it ever asks Nextcloud a question.

It routes on a query parameter and that is the extent of it:

// only asking for status?
$statusOnly = false;

if (startsWith($request, 'status')) {

So when you click Disable in the Nextcloud app store, you are flipping a row in Nextcloud's database. proxy.php does not read that row. It does not know the row exists. The file is still on disk, your web server is still serving it, and it still runs.

That's the whole explanation. It isn't a subtle bug in the disablement logic — there is no disablement logic to be subtle about, because this file predates and bypasses the entire framework it lives inside.

The Two-Shell Problem

Here is the actual defect, and it is one of the cleanest examples of a quoting bug I have seen in production code.

When a ?status request arrives and the CODE server is not currently running, proxy.php calls startCoolwsd() to launch it. That function builds a shell command. Two of the values it builds it from come out of the HTTP request:

$remoteFontConfigUrl = escapeshellarg("https://" . $_SERVER['HTTP_HOST'] . preg_replace("/richdocumentscode.*$/", "richdocuments/settings/fonts.json", $_SERVER['REQUEST_URI']));
$remoteFontConfig = "--o:remote_font_config.url=" . $remoteFontConfigUrl;
$lok_allow = "--o:net.lok_allow.host[14]=" . escapeshellarg($_SERVER['HTTP_HOST']);

Both are wrapped in escapeshellarg(). That's the correct function. Somebody was thinking about this. escapeshellarg() takes a string and returns it wrapped in single quotes with any internal single quotes neutralised, which makes it a single inert argument to a shell.

To a shell. One of them.

Then those values go here:

$launchCmd = "bash -c \"( $appImage $remoteFontConfig $IPv4only $lok_allow --pidfile=$pidfile || $appImage --appimage-extract-and-run $remoteFontConfig $IPv4only $lok_allow --pidfile=$pidfile) >/dev/null & disown\"";
exec($launchCmd, $output, $return);

PHP's exec() doesn't run your string directly. It hands it to /bin/sh -c. So /bin/sh parses that string first, and bash only ever sees what /bin/sh decides to hand it.

And /bin/sh is looking at a double-quoted string. Inside double quotes, single quotes are not quoting operators. They're literal apostrophe characters with no special meaning whatsoever. Everything escapeshellarg() did is still present in the string and completely inert, because the protection it provides only exists in a context where single quotes mean something — and inside double quotes, they don't.

What does retain meaning inside double quotes is $(...). The outer shell performs command substitution on it, runs whatever is in there, and splices the result in before bash is ever invoked.

So the defence was real, correctly implemented, and aimed one layer too shallow. escapeshellarg() protected the inner shell from the argument. Nothing protected the outer shell from the string containing the argument.

I'm not publishing a request that triggers this, and I would ask you not to go looking for one. The mechanism above is what you need to judge whether you are exposed and to recognise it in your own code, which is the entire point of writing it up. Working proof-of-concepts for an actively exploited pre-auth RCE in a file-sync server aren't a public good.


If you're enjoying what you've read so far, we recommend The Complete Self-Hosted Productivity Stack: Nextcloud, Vaultwarden, Immich, Jellyfin, and Paperless in 2026 as your next read.

The Fix, and How It Was Labelled

This is the part I keep turning over.

The fix is commit 6cc8dae54c92bae27d53c54b9898cf1123ca2066, by Caolán McNamara at Collabora. Three insertions, one deletion, in one file. Here it is in full:

-    $launchCmd = "bash -c \"( $appImage $remoteFontConfig $IPv4only $lok_allow --pidfile=$pidfile || $appImage --appimage-extract-and-run $remoteFontConfig $IPv4only $lok_allow --pidfile=$pidfile) >/dev/null & disown\"";
+
+    $innerCmd = "( $appImage $remoteFontConfig $IPv4only $lok_allow --pidfile=$pidfile || $appImage --appimage-extract-and-run $remoteFontConfig $IPv4only $lok_allow --pidfile=$pidfile) >/dev/null & disown";
+    $launchCmd = "bash -c " . escapeshellarg($innerCmd);

Build the inner command as its own string, then quote the whole thing once. Now the outer shell has nothing to interpret, because the entire payload is one properly-quoted argument. It's the right fix and it's four lines.

The commit title is: "tidy up how the coolwsd launch command is built."

I want to be careful and fair here, because it'd be easy and cheap to write this as villainy and it almost certainly isn't. There is a real, defensible reason to ship a security fix quietly — you don't want to publish a map to the bug before people have updated. Plenty of projects do exactly this, and week one of this series describes the same pattern in Vaultwarden: fix first, advisory later, CVE later still.

But there is a difference between a quiet fix and an invisible one, and the difference is whether the release tells you to take it. Vaultwarden's release notes say "this release contains security fixes for the following advisories. We strongly advise updating as soon as possible," name the advisories, and withhold only the detail. That's a quiet fix. An administrator reading it upgrades.

The 26.4.303 changelog mentions the AppImage version. An administrator reading it has no reason to prioritise anything. The fix was authored on 20 August, committed on 9 September, and released on 11 September. Disclosure came on 14 October — thirty-three days after the patch was public and nobody knew why they wanted it.

And then there is the timeline, which I want to lay out precisely because the arithmetic does the arguing.

The commit landed in the public repository at 19:20:12 UTC on 9 September — that is the commit date from the repository itself, not a report of it. The earliest logged malicious request that anyone has published is 19:51 on 10 September at UTC+2, which is 17:51 UTC. A second request from the same address followed at 09:56 the next morning.

commit public   2026-09-09 19:20:12 UTC
first attack    2026-09-10 17:51:00 UTC
                ---------------------------
gap             22h 30m

Twenty-two and a half hours. And 26.4.303 — the release that contains that commit — did not ship until 11 September, which is to say the first attack arrived a day before the fixed version was available to install. Nobody could have patched in time, because there was nothing to patch to yet. There was only a commit.

I am not going to claim I have proved causation, because I haven't, and the honest position is that this is circumstantial. Somebody could have found the bug independently in that window. But a four-line quoting change in a bash -c string, in a public repo, in an unauthenticated entry point, is about as readable as a commit gets. Anyone watching that repository for exactly this got a worked explanation of the vulnerability, and they got it before the users got a release.

That is the structural argument against silent fixes, and it requires nobody to have behaved badly. The code had to go in before the release could come out. The window is a property of how software ships, not of anyone's judgement. What you can choose is whether the release that follows tells people to take it.

The Scoring Disagreement

You will see this quoted as both 9.2 and 9.8 depending on where you look, and the gap is worth ten seconds because it tells you something real about the bug.

The vendor advisory scores it 9.2, and here is the vector it publishes:

CVSS:4.0/AV:N/AC:L/AT:P/PR:N/UI:N/VC:H/VI:H/VA:H/SC:N/SI:N/SA:N

That is CVSS 4.0, and the metric doing the work is AT:P — Attack Requirements: Present. It is the formal way of saying "this is trivial to execute, and it also needs a condition to be true that the attacker does not fully control."

Restate the same bug in CVSS 3.1 and that metric has nowhere to go, because 3.1 has no Attack Requirements. Full network reach, no authentication, no interaction, total impact on all three of confidentiality, integrity and availability gives you AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H, and that comes out at:

impact         = 5.8731
exploitability = 3.8870
base           = roundup(9.7602) = 9.8

So 9.8 in the older framework, 9.2 in the newer one, same vulnerability. In 3.1 your only choices for "needs a precondition" are to ignore it and score Attack Complexity Low, or to overstate it as High and understate how easy the rest is. CVSS 4.0 can say simple and conditional, which is precisely this bug, and the 0.6-point haircut is the framework pricing in that condition.

Which is the right moment to talk about the condition, because it is the strangest part of the whole thing.

It Only Works While the Service Is Down

From Collabora's advisory: "The attack only works while the built-in CODE server is not running."

Which follows directly from the code. startCoolwsd() is the vulnerable function, and proxy.php only calls it when there is no server to proxy to. If coolwsd is up, a ?status request gets a status back and the vulnerable path is never reached.

So your exposure window is every moment your CODE server is stopped. That means:

  • Every container restart
  • Every app or Nextcloud upgrade
  • Every crash
  • Every unattended-upgrades reboot
  • Every time the AppImage fell over and you did not notice

If you run Nextcloud in Docker and pull new images on a schedule, you have been handing out a window on a timer. And here's the nasty part: an attacker who can reach proxy.php doesn't have to wait politely for a restart. Anything that makes coolwsd fall over opens the window. A condition that depends on the service being down is a weak mitigation when the service can be encouraged to go down.

This is also why "it's been fine, I'd have noticed" isn't evidence. The window is small, repeated, and silent.

Going Looking

Patching closes the door. It doesn't evict anyone already inside. Reported post-exploitation behaviour was planting a one-line PHP web shell into the app directory, sending uname -a output to a webhook.site endpoint, and on at least one server hunting for credential stores and cryptocurrency wallets — specifically *.kdbx, wallet.dat and keystore.json. Other administrators reported configuration files containing credentials and application secrets being taken.

Now, the naming. The maintainer's own advice is to remove leftover shx*.php files, and that is the glob you will see everywhere. The shell names actually observed across the reported incidents were shxa35d0.php, shx5cf91.php, shxd33ce.php, shxf4645.php, shx13a86.php, shx56367.php — and shrf4645.php.

Look at that last one. shr, not shx. One character different, and shx*.php does not match it. I would rather you ran a glob that is slightly too wide than one that is one byte too narrow:

# Deliberately wider than the published shx*.php glob, which misses shrf4645.php
find /var/www/nextcloud/apps -name 'sh??????.php' -ls

# Anything in there newer than your last legitimate app update
find /var/www/nextcloud/apps/richdocumentscode -name '*.php' -newermt '2026-09-01' -ls

# Encoded traversal against proxy.php in access logs
grep -iE 'proxy\.php.*(%2e%2e|\$\()' /var/log/nginx/access.log*

# The reported exfiltration target, and the files the attacker went looking for
grep -riE 'webhook\.site' /var/www/nextcloud/apps/richdocumentscode 2>/dev/null
find / -xdev \( -name '*.kdbx' -o -name 'wallet.dat' -o -name 'keystore.json' \) -ls 2>/dev/null

Every observed name is sh plus six hex-ish characters plus .php, which is what sh??????.php matches. I checked both globs against all seven names in a scratch directory rather than reasoning about it:

$ find . -name 'shx*.php' | wc -l
6
$ find . -name 'sh??????.php' | wc -l
7

Six of seven against seven of seven, with no false positives on proxy.php or templates.php. If you want to be looser still, drop to sh*.php and read the handful of results by eye. The cost of a wider glob is ten seconds of looking; the cost of a narrower one is a shell you decided wasn't there.

Then let Nextcloud do the part it is good at:

sudo -u www-data php /var/www/nextcloud/occ integrity:check-app richdocumentscode
sudo -u www-data php /var/www/nextcloud/occ app:list | grep -i richdocuments

integrity:check-app compares what is on disk against the signed manifest, so an extra PHP file that nobody shipped will show up as an EXTRA_FILE. That check exists precisely for this and most people never run it.

Censys put exposure at 19,454 hosts and 103,350 web properties with richdocumentscode fingerprints. Rotate anything the web server user could read if you find evidence, and treat "I found nothing" from log grepping as weak — logs rotate, and the window you care about may have aged out.


If you're enjoying what you've read so far, we recommend Zero-Trust Access for Homelabbers: Teleport vs. Tailscale vs. WireGuard vs. Cloudflare Tunnel as your next read.

The Fix Took Three Goes

Worth knowing if you are deciding which version to land on, and I checked each of these against the actual file rather than taking anyone's summary:

Version Date What changed in proxy.php
26.4.302 31 Aug Vulnerable. REQUEST_URI and HTTP_HOST into a double-quoted bash -c
26.4.303 11 Sep The quoting fix above. Still reads REQUEST_URI
26.4.402 24 Sep No REQUEST_URI at all; SCRIPT_NAME instead, and exec() replaced with proc_open()
26.4.502 23 Oct Stops building host and font URLs from request headers; adds PID verification

The direction of travel is the lesson. 26.4.303 fixed the quoting but still fed request-controlled data into a command line. 26.4.402 stopped trusting the request and stopped using a shell at all — proc_open() with an argument array has no shell in the path, so quoting ceases to be a category of bug you can have. 26.4.502 removed the dependency on request headers entirely.

That is three releases to get from "escaped the argument" to "do not build commands out of user input," which is the actual fix, and was the actual fix in 2004 as well. Land on 26.4.502.

With one caveat I would want to know before I pulled it. 26.4.402 changed how the server is launched, and administrators reported startup failures and documents refusing to open afterwards — enough of them that some rolled back to 26.4.303 and stayed there. 26.4.502 builds on the 26.4.402 approach, so if you upgrade and Collabora stops working, you have hit a known class of problem and not a mystery. Take a snapshot first, and know that 26.4.303 is a defensible fallback: it closes this CVE, which is the thing attackers are using. Reverting to it costs you the later hardening, not the fix.

One mitigation you may already have: proxy.php checks whether exec is available before it tries, and bails if not. If your PHP has exec in disable_functions, the vulnerable path cannot execute — though it also means the built-in CODE server has never worked for you, so you'd know.

What This Doesn't Cover

I haven't reproduced the exploitation. I read the vulnerable and fixed code, traced the execution path, verified the commit and its dates in the repository, and computed the CVSS myself. I didn't stand up a vulnerable Nextcloud and attack it, and I'm not going to publish that if I do.

Standalone Collabora Online — a separate container or host, the configuration you would run for anything beyond a handful of users — is not affected. This is specific to the bundled richdocumentscode AppImage path. If you were already running Collabora properly, you've nothing to do here.

Also out of scope: Nextcloud's own advisories, which are a separate stream and did not carry this one, since the bug lives in a Collabora-maintained app rather than in nextcloud/server. That split is itself a trap — watching only nextcloud/server advisories would never have surfaced this. Incident response beyond the detection commands above, and the question of whether the built-in CODE server should exist at all, are both bigger than this piece.

The 26.4.402 and 26.4.502 changes I have characterised from reading the files at those tags. I haven't audited them for new bugs, and "later version" is an assumption about direction, not a proof of correctness.

The Actual Point

Two things, and the second one is the one that generalises.

The first is boring and you already know it: a convenience app that bundles a server inside your web root is a different risk profile from running that server separately, and the one-click option in an app store is doing more than it looks like it's doing. People who took the harder path in the first place aren't reading an incident report today. That tension is most of what self-hosters have been ripping out of their stacks this year, and it's a real trade rather than a scolding — I run convenience options too.

The second is about where your upgrade trigger comes from. If you upgrade when an advisory tells you to, your exposure is however long the project takes to publish one — thirty-three days here, and seventy-one days for the Vaultwarden advisory batch I wrote about last week. If you upgrade because a new version exists, you were patched on 11 September and never knew there'd been anything to be patched against.

The second habit is unglamorous, generates no interesting reading, and would've saved you here. I have the first habit. I'm trying to acquire the second.

Read the diff, not the changelog. The changelog is written by someone deciding what you need to know. The diff is just what happened.

$ tail -f /forums

Discussion

Comments live on the community forums — sign in there to reply.