September 13, 2026
I Found A Fourth Unserialize Sink In The Same CMS, And This Post Is The Reference That Gets The CVEβ¦
The story of a PHP object injection bug in Cotontiβs CommentsWidget

By Harsh Raj Singhania
6 min read
The story of a PHP object injection bug in Cotonti's CommentsWidget
I want to be upfront about something before getting into the technical details. This post is doing two jobs at once. It's a writeup of a real vulnerability I found. It's also the public reference that a CVE organization is waiting for before they'll publish the CVE number.
VulnCheck confirmed the issue is CVE-eligible. Then they told me: the repository hasn't received a commit in over five months and has no clear security reporting channel, so they won't facilitate coordinated disclosure. What they need before they can publish the CVE is a public reference, a blog post, a GitHub issue, a gist, something that names the product, the vulnerability type, and the affected code. That's this post.
I've already opened a GitHub issue and a pull request with a fix. Nobody has responded. The maintainers didn't reply to an email I sent in August. So this writeup is the mechanism.
Now let me actually explain what I found.
What Cotonti Is
Cotonti is a PHP CMF, a content management framework, that's been around since 2008. It's the kind of project people use to build community websites, forums, and content-heavy sites that don't fit neatly into something like WordPress. It has a modular plugin system, and one of the default plugins is a full-featured comments system.
Cotonti 1.0.0, released in 2024, is the current version. The repository hasn't been updated since.
The Bug That Made Me Go Looking
CVE-2026β71294 was found by another researcher and published publicly. It documented three separate unserialize() calls in Cotonti's Comments plugin that accept user-supplied data without restricting which PHP classes can be instantiated. Two of the sinks were in CreateAction.php, reachable by any member with write access to comments. The third was in EditAction.php, reachable by a member editing their own comment. All three required either write access or comment ownership.
I read that report and had a simple question: were those the only unserialize() calls in the comments plugin?
They weren't.
The Fourth Sink
plugins/comments/inc/CommentsWidget.php is the file that handles embedding the comment widget anywhere on a Cotonti site. When a widget request comes in over AJAX, the run() method reads a ci GET parameter that carries state about the current page, decodes it, and uses it to reconstruct URL context. Here's the vulnerable section as it shipped in Cotonti 1.0.0:
if (COT_AJAX && Cot::$env['ext'] === 'comments' && isset($_GET['ci'])) {
$ci = cot_import('ci', 'G', 'TXT');
if (!empty($ci)) {
$ci = @unserialize(base64_decode($ci));
if (!empty($ci)) {
$this->currentUrlExtension = $ci[0];
$this->currentUrlParams = $ci[1];
}
}
}if (COT_AJAX && Cot::$env['ext'] === 'comments' && isset($_GET['ci'])) {
$ci = cot_import('ci', 'G', 'TXT');
if (!empty($ci)) {
$ci = @unserialize(base64_decode($ci));
if (!empty($ci)) {
$this->currentUrlExtension = $ci[0];
$this->currentUrlParams = $ci[1];
}
}
}Three lines, one unserialize, no allowed_classes. That's a PHP object injection sink.
Why cot_import() Doesn't Help Here
Cotonti has its own input sanitization function, cot_import(), and the developer used it here, cot_import('ci', 'G', 'TXT'). Looking at that, you might think the input was sanitized. It wasn't, in any way that matters for this bug.
The TXT filter trims whitespace and strips HTML tags. That's the right thing to do if the value is going to be displayed in a page. It does absolutely nothing to a base64-encoded PHP serialized object, because base64 is made of printable ASCII characters, letters, numbers, +, /, =. Not a single HTML tag, not a byte of whitespace in a way that would affect parsing. The crafted payload rides through the filter character-for-character intact.
This is the deceptive part. A developer reading this code sees cot_import() and thinks: input handled. The security question is handled upstream. But TXT filtering and deserialization safety are completely different concerns, and using one doesn't get you the other.
Proving It
Same rule as every previous post: test the actual code path, not a reimagined version of it.
I built a standalone PHP script that mirrors the exact flow from run(). First, it constructs a gadget object, a class with a __wakeup() magic method that writes a sentinel file to disk the moment it's instantiated. That's the proof of execution: if that file shows up, the magic method ran.
Then it simulates cot_import('ci', 'G', 'TXT'), showing that the base64 payload comes out identical to what went in:
After cot_import('ci','G','TXT'): value is identical β base64 passes unmodified.
raw = TzoxNToiRGFuZ2Vyb3VzR2FkZ2V0IjoxOntzOjc6ImNvbW1hbmQiO3M6MTI6...
out = TzoxNToiRGFuZ2Vyb3VzR2FkZ2V0IjoxOntzOjc6ImNvbW1hbmQiO3M6MTI6...After cot_import('ci','G','TXT'): value is identical β base64 passes unmodified.
raw = TzoxNToiRGFuZ2Vyb3VzR2FkZ2V0IjoxOntzOjc6ImNvbW1hbmQiO3M6MTI6...
out = TzoxNToiRGFuZ2Vyb3VzR2FkZ2V0IjoxOntzOjc6ImNvbW1hbmQiO3M6MTI6...Same string. The filter touched nothing.
Then it runs the vulnerable unserialize line:
--- TEST 1: Vulnerable path (no allowed_classes) ---
[MAGIC METHOD FIRED] DangerousGadget::__wakeup() executed with command: id && whoami
CONFIRMED: sentinel file written by gadget's __wakeup()--- TEST 1: Vulnerable path (no allowed_classes) ---
[MAGIC METHOD FIRED] DangerousGadget::__wakeup() executed with command: id && whoami
CONFIRMED: sentinel file written by gadget's __wakeup()The file exists. The magic method ran. The attacker-controlled serialized payload was instantiated by PHP the moment it hit unserialize(). Whatever a real application has in its codebase, database wrappers, file handlers, logging classes, anything with a __wakeup or __destruct that does something destructive, becomes a potential weapon once an attacker can supply the serialized object graph.
Then the fix:
--- TEST 2: Fixed path (allowed_classes => false) ---
SAFE: unserialize returned an __PHP_Incomplete_Class stub β __wakeup() never fired.
Class: __PHP_Incomplete_Class (incomplete β gadget not instantiated)--- TEST 2: Fixed path (allowed_classes => false) ---
SAFE: unserialize returned an __PHP_Incomplete_Class stub β __wakeup() never fired.
Class: __PHP_Incomplete_Class (incomplete β gadget not instantiated)With allowed_classes => false, PHP deserializes the data structure but refuses to instantiate any real class. The gadget becomes an __PHP_Incomplete_Class placeholder that can't run magic methods. The sentinel file was never written.
Why The Attack Surface Is Larger Than CVE-2026β71294
The three sinks in that CVE all needed POST requests. Creating a comment, editing a comment, both require form submissions.
This sink fires on a GET request. An attacker just needs to craft a URL with the right ci parameter and get an authenticated user to visit it. In practical terms that means this vulnerability is reachable through link clicking, embedding, or request forgery, without any form interaction required.
The privilege requirement is also lower. CVE-2026β71294's write-access sink required comment write permissions. This sink only requires read access to comments, which is the default setting for members in plugins/comments/comments.setup.php. Read access is what you get just by having an account on a Cotonti site with comments enabled.
The Fix
It's one line, plus some structural validation that should have been there from the start:
// Vulnerable (Cotonti 1.0.0):
$ci = @unserialize(base64_decode($ci));
// Fixed (PR #1889):
$ci = @unserialize(base64_decode($ci), ['allowed_classes' => false]);
if (
is_array($ci)
&& array_key_exists(0, $ci)
&& is_string($ci[0])
&& array_key_exists(1, $ci)
&& is_array($ci[1])
) {
$this->currentUrlExtension = $ci[0];
$this->currentUrlParams = $ci[1];
}// Vulnerable (Cotonti 1.0.0):
$ci = @unserialize(base64_decode($ci));
// Fixed (PR #1889):
$ci = @unserialize(base64_decode($ci), ['allowed_classes' => false]);
if (
is_array($ci)
&& array_key_exists(0, $ci)
&& is_string($ci[0])
&& array_key_exists(1, $ci)
&& is_array($ci[1])
) {
$this->currentUrlExtension = $ci[0];
$this->currentUrlParams = $ci[1];
}allowed_classes => false is PHP's built-in mechanism for safe deserialization of data you don't fully trust. It's been available since PHP 7. The structural validation that follows makes sure the decoded value is actually the [extensionCode, urlParams] array the code expects, rather than accepting arbitrary data of any shape.
I wrote this fix, opened issue #1888 with the full technical report, submitted PR #1889, and emailed the maintainers directly. No response to the email. No response on GitHub.
CVSS 4.0: 9.4 Critical
This was scored Critical in the CNA-LR submission, using the vector CVSS:4.0/AV:N/AC:L/AT:N/PR:L/UI:N/VC:H/VI:H/VA:H/SC:H/SI:H/SA:H. In plain terms: reachable over the network with no special conditions, requires only a low-privilege member account, no user interaction, high impact on confidentiality, integrity, and availability. The Critical score reflects the combination of wide reach and what PHP object injection can do once a usable gadget chain exists in the application.
The Disclosure Situation
When I filed this with VulnCheck, they confirmed it was CVE-eligible. But because the Cotonti repository has been inactive for months with no security contact, they told me they'd decline to coordinate disclosure. They need a public reference first.
This post is that reference.
Once VulnCheck sees this published, they'll proceed with CVE publication. At that point the CVE number will be assigned and I'll add it here. The MITRE CNA-LR form is tracked under CAN-2026β2035973.
The upstream repository is effectively abandoned. Cotonti 1.0.0 is the latest release. Any site running it with the comments plugin and default member permissions is affected. There is no vendor patch available, only my fork contains the fix at this time.
If You're Running Cotonti
The cleanest fix is to apply the allowed_classes => false change in plugins/comments/inc/CommentsWidget.php manually. The full patched version is in my fork at the remediation commit. Until a maintainer merges the fix, you can also disable the comments plugin entirely, or restrict comment read access to trusted users only, though the latter doesn't fix the underlying sink.
What This Makes Five
This is the seventh post in this series, covering bugs across Perl, Rust, C++, and now PHP. Every single one started with reading something, someone else's CVE, a single ambiguous phrase, or in this case a count that felt incomplete. CVE-2026β71294 described three sinks. I went looking for a fourth. That's all it took.
I'm a second year cybersecurity student. If you're a Cotonti maintainer or user and want to discuss the fix, the issue and PR are open on GitHub.
References
- GitHub issue #1888 (full technical report): https://github.com/Cotonti/Cotonti/issues/1888
- GitHub PR #1889 (the fix): https://github.com/Cotonti/Cotonti/pull/1889
- Remediation commit on my fork: https://github.com/HarshRajSinghania/Cotonti/commit/fa6a45a15117f8b9b0da3fcd0f5dd260659e0419
- Cotonti 1.0.0 source, the vulnerable file: https://github.com/Cotonti/Cotonti/blob/1.0.0/plugins/comments/inc/CommentsWidget.php
- CVE-2026β71294 (the three sinks that made me look, not mine): https://nvd.nist.gov/vuln/detail/CVE-2026-71294
- Cotonti 1.0.0 release: https://www.cotonti.com/news/announce/cotonti-verona-100-released
- Cotonti source repository: https://github.com/Cotonti/Cotonti
- My previous post, CVE-2026β86547 in mrubyc: https://medium.com/@harshrajsinghania/i-read-someone-elses-bug-report-then-found-the-same-missing-check-in-the-next-function-over
- My post on CVE-2026β87961 in ESP32-audioI2S: https://medium.com/@harshrajsinghania/the-bug-was-real-the-one-filed-next-to-it-wasn-t
- My post on CVE-2026β82452 and CVE-2026β82453: https://medium.com/@harshrajsinghania/i-found-two-more-cves-by-checking-one-word-in-someone-else-s-bug-report
- My post on CVE-2026β78030: https://medium.com/@harshrajsinghania/i-went-back-into-the-same-repo-on-purpose-and-found-something-worse
- My first post, CVE-2026β73194: https://medium.com/@harshrajsinghania/i-found-my-first-cve-by-trying-to-understand-someone-elses-bug-the-story-of-cve-2026-73194-1702f8bde5c1