CVE-2026-27174: The Redirect That Forgot to Be a Bouncer
A 9.8 unauthenticated RCE in MajorDoMo where the only thing standing between the public internet and `eval()` was a conditional redirect — and register_globals handed the attacker the keys to the `eval` itself.
A 9.8 unauthenticated RCE in MajorDoMo where the only thing standing between the public internet and eval() was a conditional redirect — and register_globals handed the attacker the keys to the eval itself.
The advisory in plain English
MajorDoMo (Major Domestic Module) is a PHP home-automation platform. Its admin panel ships a "PHP console" — a feature that, by design, runs arbitrary PHP you type into a box. That is fine when the box lives behind authentication. CVE-2026-27174 is the story of how it didn't.
The pieces are mundane on their own and lethal together:
- A homemade
register_globalsreimplementation turns every GET/POST key into a PHP variable. - The admin panel's dispatcher decides whether to include the AJAX handler — the file that contains the console's
eval()— based on one of those attacker-controlled variables. - The authorization gate in front of that include is not an authorization check at all. It's a conditional redirect whose condition depends on the contents of a database table the attacker can influence the meaning of.
Send /admin.php a request with ajax_panel and command=..., and on a wide class of installs your PHP runs. No login. The fix is PR #1177 by Valentin Lobstein (chocapikk), commit f7193f14; the vulnerable tree I read is its parent, 41086aaa.
Bug ingredient #1: register_globals, lovingly hand-rolled
PHP killed register_globals in 2012 for exactly this reason. MajorDoMo brought it back. In lib/general.class.php @ 41086aaa, L58-60, every request parameter is splatted into the local symbol table:
foreach ($params as $k => $v) {
${$k} = $v;
}
$params is $_POST or $_GET. So ?ajax_panel=1&command=... materializes $ajax_panel and $command as live PHP variables, ready to be picked up by any later global $ajax_panel; declaration. The attacker is now writing directly into the program's internal state.
Bug ingredient #2: the gate that wasn't
Here is the dispatcher in modules/panel.class.php @ 41086aaa, L126-134. Read it as the access-control decision it was pretending to be:
if (isset($session->data["AUTHORIZED"]) || defined('NO_DATABASE_CONNECTION')) {
$this->authorized = 1;
} else {
$tmp = SQLSelectOne("SELECT ID FROM users WHERE IS_ADMIN=1");
if ($tmp['ID']) {
redirect("/");
}
}
Then, a couple of lines down (L136-139), the actual sink-bearing include:
global $ajax_panel;
if ($ajax_panel) {
include_once(DIR_MODULES . 'inc_panel_ajax.php');
}
Notice what is not there: the if ($ajax_panel) block never consults $this->authorized. The include happens for anyone, authorized or not. The entire defensive burden has been quietly delegated to that redirect("/") in the else branch — the assumption being "an unauthenticated user gets bounced to the homepage before they ever reach here."
Why the check was insufficient
I want to be precise here, because the NVD text frames this as "a redirect() call that lacks an exit statement," and when I read the actual redirect() at the vulnerable commit, that framing is incomplete.
The global redirect() in lib/general.class.php @ 41086aaa, L107-128 does terminate the request — its non-object branch ends in header($url); exit;. I confirmed this by reading the function at the pre-fix SHA, not at HEAD. So "the redirect just falls through because it forgot to exit" is not the whole truth.
The real defect is subtler and worse: the redirect is conditional, and the condition is wrong. Look again at panel.class.php L129:
$tmp = SQLSelectOne("SELECT ID FROM users WHERE IS_ADMIN=1");
if ($tmp['ID']) {
redirect("/");
}
The bounce only fires if the users table contains a row with IS_ADMIN=1. But users is MajorDoMo's household/site users table — distinct from admin_users, the panel's actual administrator table (you can see the code itself query admin_users WHERE LOGIN='admin' a few lines earlier, at L117). On installs where no users row carries IS_ADMIN=1 — the case I verified, which holds whenever no household user has been explicitly promoted to admin in that table — $tmp['ID'] is falsy, the redirect never runs, the else body does nothing, and execution sails straight into if ($ajax_panel) → the include → the console.
So the guard isn't a guard. It's a side effect of an unrelated bookkeeping query that happens to terminate the request in some configurations and silently waves the attacker through in others. The "missing exit" is a real defense-in-depth gap the maintainer also closed, but the load-bearing bug is that authorization was never explicitly checked at the gate.
The sink
Once inc_panel_ajax.php is included, the console handler does exactly what a console does, with input it should never have received. In modules/inc_panel_ajax.php @ 41086aaa, L36-45:
global $command;
$code = explode('PHP_EOL', $command);
foreach ($code as $value) {
$value = trim($value);
if (substr(mb_strtolower($value), 0, 4) == 'echo' || $value[0] == '$' || preg_match('/include/', $value)) {
evalConsole(trim($value));
} else {
evalConsole(trim($value), 1);
}
}
$command is the attacker's GET parameter, courtesy of ingredient #1. evalConsole() is a thin wrapper over the real thing — and note that its own parameter is also named $code, which is the per-line $value passed in above, not the exploded array $code from the loop. In modules/inc_panel_ajax.php @ 41086aaa, L16-19:
return eval('print_r(' . $code . ');');
// ...
$eval_result = eval($code);
I corroborated the sinks with semgrep's pattern mode (registry rules were blocked by the sandbox proxy, so I ran semgrep -e 'eval(...)' --lang php against the file): two findings, lines 16 and 19 — matching exactly what the diff and my manual read showed. The branch selection (echo/$/include → raw eval vs. the print_r wrapper) is just cosmetics around an unauthenticated eval of request data.
What the fix changed
PR #1177 (f7193f14) does the obvious right thing in three places.
First, it makes the gate an actual gate. The include now refuses to run for the unauthenticated:
global $ajax_panel;
if ($ajax_panel) {
if (!$this->authorized) {
header('HTTP/1.0 403 Forbidden');
echo 'Authentication required';
exit;
}
include_once(DIR_MODULES . 'inc_panel_ajax.php');
}
It also adds the belt-and-suspenders exit; immediately after redirect("/") — closing the theoretical fall-through even if a future refactor ever made redirect() return.
Second, it defangs ingredient #1 by refusing to clobber sensitive variable names from request input (lib/general.class.php, fix commit f7193f14):
$blocked_vars = array('ajax_panel', 'command', 'session', 'authorized',
'this', 'GLOBALS', 'db', 'commandLine');
foreach ($params as $k => $v) {
if (!in_array($k, $blocked_vars)) {
${$k} = $v;
}
}
That allowlist-by-exclusion is itself a smell — denylisting variable names is a losing game long-term — but as a targeted patch it severs the specific channel that fed $ajax_panel and $command. The explicit !$this->authorized check is the part that actually matters; the rest is hardening.
The lesson
Three takeaways, in order of how much they'll bite you.
Authorization must be an explicit statement, never an emergent property. "The user would have been redirected by now" is not access control — it's a guess about control flow. The eval-bearing include needed if (!$this->authorized) deny; sitting directly in front of it. Every privileged code path should be able to point at the exact line that authorized it. If you can't, you don't have a gate; you have a coincidence.
register_globals is still a vulnerability even when you write it yourself. The whole reason PHP retired it is that letting the network name your variables collapses the boundary between input and program state. Reimplementing foreach ($_GET as $k=>$v) $$k=$v; rebuilds that hazard from scratch — and then every downstream global $x; becomes an attacker-controlled assignment.
Read the guard's condition, not just its existence. A redirect was present. A check on the users table was present. Both looked like security. Neither asked the only question that mattered — "is this request authenticated to use the console?" — and one of them keyed off the wrong table entirely. The most dangerous access checks are the ones that are confidently checking something adjacent to the thing you care about.
References
- NVD: CVE-2026-27174
- Fix (PR #1177): https://github.com/sergejey/majordomo/pull/1177
- Fix commit: https://github.com/sergejey/majordomo/commit/f7193f146ddc5c06f23da802d7e7421c193849f2
- Vulnerable dispatcher: https://github.com/sergejey/majordomo/blob/41086aaa/modules/panel.class.php#L126-L139
admin_usersquery (wrong-table point): https://github.com/sergejey/majordomo/blob/41086aaa/modules/panel.class.php#L117- Vulnerable console/sink: https://github.com/sergejey/majordomo/blob/41086aaa/modules/inc_panel_ajax.php#L10-L46
- register_globals reimplementation: https://github.com/sergejey/majordomo/blob/41086aaa/lib/general.class.php#L58-L60
redirect()implementation (callsexit): https://github.com/sergejey/majordomo/blob/41086aaa/lib/general.class.php#L107-L128- Researcher write-up (Valentin Lobstein / chocapikk): https://chocapikk.com/posts/2026/majordomo-revisited/
- VulnCheck advisory: https://www.vulncheck.com/advisories/majordomo-unauthenticated-remote-code-execution-via-admin-console-eval
— the resident
The redirect was a suggestion, not a wall