Safeguard
Vulnerability Analysis•

CVE-2026-110447: How a Quoting Bug in Nextcloud's Built-in Collabora Server Became Exploited RCE

CVE-2026-110447 is an unauthenticated command injection in "Collabora Online - Built-in CODE Server", the Nextcloud app (richdocumentscode) that runs a bundled Collabora office server inside a Nextcloud install.

Safeguard Security Team
Security Research
11 min read

CVE-2026-110447 is an unauthenticated command injection in "Collabora Online - Built-in CODE Server", the Nextcloud app (richdocumentscode) that runs a bundled Collabora office server inside a Nextcloud install. One crafted GET request runs shell commands as the web server user whenever the bundled server isn't running. Attackers have used it to drop web shells since at least 10 September 2026. The fix shipped on 11 September in 26.4.303, without a security note. The CVE was published on 14 October.

This post is for anyone who runs Nextcloud with the built-in CODE server, or who reviews PHP that builds shell commands. It covers who is affected, how a URL path turns into a shell command in proxy.php, a harmless reproduction of the quoting bug, a script that checks a server for the published indicators, and what to rotate if you find them.

The short answer

Update richdocumentscode to 26.4.402, the current release. 26.4.303 is the minimum fixed version. Then check for web shells, because exploitation started before most people knew there was a bug. Every release we checked from 23.5.403 (October 2023) to 26.4.302 contains the vulnerable code. Standalone Collabora Online servers aren't affected. Only the built-in deployment, where the Nextcloud app downloads and launches its own AppImage, is.

CVECVE-2026-110447, CVSS 4.0 9.2
ComponentNextcloud app richdocumentscode ("Collabora Online - Built-in CODE Server")
AffectedBefore 26.4.303. The vulnerable code is present from 23.5.403 (Oct 2023)
Fixed26.4.303 (11 Sep 2026); restructured in 26.4.402 (24 Sep 2026)
ExploitedYes, first public report 11 Sep 2026 (issue #354)
PreconditionThe bundled CODE server isn't running, for example after a restart

Timeline

  • October 2023: release 23.5.403 starts passing a remote font configuration URL, built from the request URI, to the server launch command.
  • 9 September 2026: the fix is committed as "tidy up how the coolwsd launch command is built".
  • 10 September: the first malicious requests appear in an administrator's logs.
  • 11 September: 26.4.303 is released. The changelog lists only the bundled CODE version. The same day, the administrator files issue #354 with logs and the recovered web shell.
  • 14 to 18 September: maintainers confirm 26.4.303 fixes it and advise removing leftover files. More victims report in the issue.
  • 24 September: 26.4.402 replaces the shell string with an argument array.
  • 3 October: a Nextcloud forum post warns built-in users to update, noting that no CVE or advisory exists yet.
  • 14 October: MITRE publishes CVE-2026-110447, noting exploitation in September and October.

How a URL path becomes a shell command

proxy.php is a standalone script in the app directory. It doesn't load Nextcloud or check a login. It is the relay between Nextcloud and the bundled office server. A request for proxy.php?status checks whether that server (coolwsd) is listening on port 9983. If it isn't, the script starts it, so a status check doubles as an unauthenticated "start the server" button.

In 26.4.302, startCoolwsd() builds the launch command like this (trimmed):

$remoteFontConfigUrl = escapeshellarg("https://" . $_SERVER['HTTP_HOST'] .
    preg_replace("/richdocumentscode.*$/", "richdocuments/settings/fonts.json", $_SERVER['REQUEST_URI']));
$remoteFontConfig = "--o:remote_font_config.url=" . $remoteFontConfigUrl;
...
$launchCmd = "bash -c \"( $appImage $remoteFontConfig $IPv4only $lok_allow --pidfile=$pidfile || ... ) >/dev/null & disown\"";
exec($launchCmd, $output, $return);

Three details combine.

The URL comes from REQUEST_URI, which keeps whatever the client sent. An attacker requests /x$(command)x/%2e%2e/apps/richdocumentscode/proxy.php?status. The web server decodes %2e%2e to .. and resolves the path, so the request is routed to proxy.php. But REQUEST_URI still holds the raw string, and the preg_replace only rewrites from richdocumentscode onwards, so the $(command) prefix survives into the URL.

escapeshellarg() was applied, but at the wrong layer. It wraps the URL in single quotes, which is correct for a string the shell will parse once. Here the result is pasted inside a double-quoted bash -c "..." argument. PHP's exec() hands the whole line to /bin/sh, which parses the double-quoted string first. Inside double quotes, single quotes are ordinary characters and $(...) is still expanded. So the outer shell runs the attacker's command before bash ever sees the "escaped" URL.

The window is any time coolwsd isn't running. That's normal after a reboot, a PHP-FPM restart, or an AppImage update. Scanning for the endpoint is cheap, so the first request after a restart wins. The CVSS 4.0 vector reflects this with AT:P, meaning attack requirements are present.

The 26.4.303 fix wraps the entire inner command in escapeshellarg(), so the outer shell sees a single-quoted string and passes it to bash untouched. 26.4.402 goes further. It builds an argument array for proc_open(), so no shell ever parses the values, and it derives the font URL from SCRIPT_NAME, the path the server actually resolved, instead of REQUEST_URI.

The Host header went into the same double-quoted string through the same escapeshellarg() call. Both fixes cover it. We didn't test whether common web servers would pass a Host value containing $(.

Reproducing the quoting bug safely

You don't need Nextcloud to see the bug. This Python script rebuilds the 26.4.302 and 26.4.303 command strings exactly as PHP would, using PHP's quoting rules, and runs them through /bin/sh -c the way exec() does. The payload only creates a file called INJECTED, and /bin/echo stands in for the AppImage.

import os, re, subprocess

def escapeshellarg(s):                 # PHP's escapeshellarg() on Linux/macOS
    return "'" + s.replace("'", "'\\''") + "'"

# The raw request path an attacker sends; the web server resolves "/x...x/.." away
# when routing, but PHP's REQUEST_URI keeps it. Payload: create a marker file.
request_uri = "/x$(touch${IFS}INJECTED)x/%2e%2e/apps/richdocumentscode/proxy.php?status"
url = escapeshellarg("https://cloud.example" +
      re.sub(r"richdocumentscode.*$", "richdocuments/settings/fonts.json", request_uri))
font_opt = "--o:remote_font_config.url=" + url
app = "/bin/echo"                       # stands in for the CODE AppImage

for label, cmd in [
    ("26.4.302", f'bash -c "( {app} {font_opt} ) >/dev/null"'),
    ("26.4.303", "bash -c " + escapeshellarg(f"( {app} {font_opt} ) >/dev/null")),
]:
    if os.path.exists("INJECTED"):
        os.remove("INJECTED")
    subprocess.run(["/bin/sh", "-c", cmd])          # what PHP exec() does
    print(f"{label}: command injected = {os.path.exists('INJECTED')}")
26.4.302: command injected = True
26.4.303: command injected = False

The general rule: escapeshellarg() protects a value for exactly one round of shell parsing. If its output then goes inside another quoted layer that a shell parses again, the protection is gone. Passing an argument array to proc_open(), as 26.4.402 now does, avoids the problem entirely.

What the attackers did

The administrator who opened issue #354 published logs, payloads and indicators from several compromised servers. In summary:

  • Requests to /apps/richdocumentscode/proxy.php?status with %2e%2e traversal, ${IFS} instead of spaces, and a base64 payload piped to sh.
  • The payload wrote a one-line PHP web shell named shx<hex>.php into the app directory, for example shxa35d0.php. The shell runs whatever is passed in the c query parameter. The payload then called back to a webhook.site URL with the output of uname -a.
  • On one server, the attacker used the shell to look for password manager vaults (*.kdbx, Bitwarden, 1Password) and cryptocurrency wallets.
  • The reported indicators include source IP 139.28.18.122 and SHA-256 4b9fbd0663fbfd6028fc216ed18e0d6ef3ae498a7fdc97083e4b0ce3cec40562 for one recovered shell.

A second reporter found coolwsd processes still carrying the injected font-config URL on their command line after upgrading. A maintainer said those should be harmless, but restarting them is cheap.

We are not publishing a working request. The pattern above is already public in the issue, and the fix is available.

Check a server

This script checks the four things that matter on a Nextcloud host: the installed app version, PHP files in the app directory that look like the published web shell, exploit-shaped requests in access logs (plain or gzipped), and running coolwsd processes with an injected URL. It uses only the Python standard library. Run it as a user who can read the Nextcloud directory and the logs.

#!/usr/bin/env python3
"""Check a Nextcloud host for CVE-2026-110447 (Built-in CODE Server, richdocumentscode).
1) installed app version, 2) web shells in the app directory, 3) exploit requests in
access logs (plain or .gz), 4) running processes still carrying an injected command line.
Usage: python3 code_ioc_check.py <nextcloud_root> [access_log ...]"""
import gzip, pathlib, re, subprocess, sys

root = pathlib.Path(sys.argv[1]); logs = sys.argv[2:]
findings = 0
def flag(msg):
    global findings; findings += 1; print("  [!] " + msg)

app = next((p for p in [root / "apps" / "richdocumentscode", root / "custom_apps" / "richdocumentscode"]
            if p.is_dir()), None)
print("1. App version")
if not app:
    print("  richdocumentscode not installed here")
else:
    m = re.search(r"<version>([\d.]+)</version>", (app / "appinfo" / "info.xml").read_text())
    ver = tuple(int(x) for x in m.group(1).split(".")) if m else (0,)
    print(f"  {app}: {m.group(1) if m else 'unknown'}")
    if ver < (26, 4, 303):
        flag("vulnerable version, update to 26.4.303 or later")

    print("2. PHP files that look like web shells in the app directory")
    for php in app.rglob("*.php"):
        text = php.read_text(errors="ignore")
        if php.name.startswith("shx") or re.search(r"(shell_exec|system|passthru)\s*\(\s*\$_?(GET|c)\b", text):
            flag(f"suspicious PHP file: {php}")

print("3. Exploit requests in access logs")
EXPLOIT = re.compile(r"(\$\(|%24%28|\$\{IFS\}|%24%7BIFS).*richdocumentscode/proxy\.php", re.I)
for log in logs:
    opener = gzip.open if log.endswith(".gz") else open
    with opener(log, "rt", errors="ignore") as fh:
        for n, line in enumerate(fh, 1):
            if EXPLOIT.search(line):
                flag(f"{log}:{n}: {line[:110].rstrip()}...")

print("4. Running processes with an injected font-config URL")
ps = subprocess.run(["ps", "-eo", "pid,args"], capture_output=True, text=True).stdout
for line in ps.splitlines():
    if ("coolwsd" in line or "Collabora_Online.AppImage" in line) and "code_ioc_check" not in line \
            and re.search(r"remote_font_config\.url=\S*(\$\(|\$\{IFS\})", line):
        flag(f"process: {line[:110]}...")
print(f"\n{findings} finding(s)")
sys.exit(1 if findings else 0)

Against a test directory with the vulnerable version, a planted copy of the published shell and a log containing one exploit-shaped request next to a normal status check, it reports:

$ python3 code_ioc_check.py nc access.log.gz
1. App version
  nc/apps/richdocumentscode: 26.4.302
  [!] vulnerable version, update to 26.4.303 or later
2. PHP files that look like web shells in the app directory
  [!] suspicious PHP file: nc/apps/richdocumentscode/shx0test.php
3. Exploit requests in access logs
  [!] access.log.gz:1: 203.0.113.7 - - [10/Sep/2026:19:51:52 +0200] "GET /x$(echo${IFS}dGVzdA==|base64${IFS}-d|sh)x/%2e%2e/apps/richd...
4. Running processes with an injected font-config URL

3 finding(s)

It flags only the malicious request, not the legitimate ?status call. We also confirmed the process check against a test process carrying an injected remote_font_config.url argument. A clean result is good news, but not proof. Attackers with a shell can delete files and logs, and the shell name pattern is only what was reported so far.

If you were exposed

Treat any hit as a compromise of the web server account. On a typical install, that account can read config.php, the data directory and everything Nextcloud can reach.

  1. Update to 26.4.402, then stop any running coolwsd processes so the app relaunches them with clean arguments.
  2. Remove web shells from apps/richdocumentscode/ (or custom_apps/), then run occ integrity:check-app richdocumentscode and occ integrity:check-core to find other unexpected files. One reporter caught the intrusion this way.
  3. Rotate what the web server could read: the database password, secret and passwordsalt in config.php, SMTP and object-storage credentials, and app passwords. Check for new admin users and unfamiliar cron entries or .php files elsewhere in the web root.
  4. Search logs as far back as you have them. The first reports were from 10 September, but the vulnerable code shipped in October 2023 and the attackers may not have been the first to find it.
  5. Decide whether you need the built-in server at all. Collabora's maintainer described the built-in mode as a rarer deployment that runs the Development Edition. A standalone Collabora Online server isn't affected by this bug.

The pattern behind it

This bug has two familiar halves. In the code, a value was escaped for one shell and then handed to two. In the process, a silent fix under a "tidy up" commit message shipped the day after exploitation started, and the CVE arrived five weeks later. It's also invisible to most dependency scanners, because a Nextcloud app isn't in any package ecosystem they read. For self-hosted PHP apps, the release history and the project's issue tracker are often the only warning you get, which makes it worth subscribing to both for anything you expose to the internet.

To be fair to the maintainers, they responded within days of the report, confirmed the fixed version, and followed up with a better structural fix two weeks later. The gap was in telling users. A security line in the 26.4.303 changelog would have reached administrators three weeks before the forum warning did.

Never miss an update

Weekly insights on software supply chain security, delivered to your inbox.

Self-healing security runs on Safeguard.

Your first fix PR is minutes away.

No sales call required, even your agent can complete the purchase over MCP.