Skip to content

Add behaviorally-validated GLPI CVE/GHSA detection modules#12

Closed
UncleJ4ck wants to merge 52 commits into
Orange-Cyberdefense:mainfrom
UncleJ4ck:main
Closed

Add behaviorally-validated GLPI CVE/GHSA detection modules#12
UncleJ4ck wants to merge 52 commits into
Orange-Cyberdefense:mainfrom
UncleJ4ck:main

Conversation

@UncleJ4ck

@UncleJ4ck UncleJ4ck commented Jun 15, 2026

Copy link
Copy Markdown

This PR brings the fork's GLPI CVE/GHSA detection modules upstream and reworks them to validate behaviorally rather than by version string. Each module's check() is required to fire against a live vulnerable GLPI and stay silent against the patched release, with a benign control to rule out false signals. Every module was exercised against a lab spanning GLPI 9.5.x, 10.0.x and 11.0.x on PHP 7.4 and 8.x, on both the vulnerable and patched side. 73 CVE/GHSA modules, none firing on a patched target.

Several inherited modules were incorrect and are fixed here:

  • CVE-2024-43416 previously confirmed only that the lost-password page loaded, so it reported every instance as vulnerable, patched ones included. Replaced with a timing oracle: the 10.0.17 fix adds sleep(rand(1,3)), so an invalid email returns in milliseconds on a vulnerable build and 1 to 3 seconds on a patched one.
  • CVE-2023-41326 and CVE-2024-37149 shipped without a check() and crashed on --check. Both now have behavioral checks.
  • CVE-2023-43813's ORDER BY payload was constant-folded by MariaDB, so the SLEEP never executed. The connector is now a real second sort expression.
  • CVE-2024-27096 targeted map.php, which is CVE-2024-31456's sink. It now targets its real search.php manageParams path and proves the 10.0.13 fix.

Merge note: one conflict, in CVE-2026-26026. I kept the @author Zax credit and adjusted @cvss from 6.4 to 7.2. GHSA-2c98-648q-h27h scores it 7.2 and 9.1, and the module's run() achieves code execution via shell_exec (verified on 11.0.2, run(cmd=id) returned uid=33(www-data)), so the SSTI-only classification understated it. The 11.0.6 fix is as the module describes: QuestionTypeDropdown concatenates the parent render after renderFromStringTemplate(), breaking the double compilation.

Testing: for each module, deploy the release immediately before the fix and the first fixed release, run --check against both, and confirm it fires on the former and stays silent on the latter. Destructive checks (_is_check_opsec_safe = False) create artifacts and purge them in a finally. PHP-7.4-only SQLi modules were validated on same-PHP vulnerable and patched instances, so the silence on the patched side reflects the fix and not PHP 8 dropping the payload.

This is a large changeset. If splitting it per CVE or per era is easier to review, I can do that.

j4kuuu added 30 commits May 10, 2026 16:15
…n_process_update guard, not page accessibility)
…l vectors: OOB SSRF, object-instantiation RCE, API KB visibility)
Behaviorally validated each check against vulnerable and patched live
GLPI deployments (fires on vuln, silent on patched, with negative controls):

- CVE-2025-64516: rewrite wrong vector to the real authenticated Document
  BOLA (canViewFileFromItem missing documents_id); canary exfil. Cover both
  10.0.x (<10.0.21) and 11.0.x (<11.0.3) branches.
- CVE-2024-43418: fix misattribution (was a non-existent getConfig SQLi);
  real reflected XSS in stat.tracking.php value2 attribute breakout.
- CVE-2024-47760: fix admin-session false-negative; spawn an Admin actor
  and a second super-admin victim, verify is_active strip effect.
- CVE-2024-47761: fix inverted check; Observer reads a Self-Service target's
  password_forget_token (key presence differential).
- CVE-2026-22044: fix false-negative vector to the real getDropdownValue
  order sink (IDOR token + CSRF + paired-backtick ORDER BY SLEEP).
- New modules: CVE-2022-39323, CVE-2023-46727, CVE-2024-11955, CVE-2026-25932,
  CVE-2026-42317.
- Remove cve_2023_36810 (pypdf DoS, not a GLPI vulnerability).
The check targeted the wrong sub-vector (CPU designation, a PHP string that
DBmysql::escape() escapes in both versions, via the async INVENTORY query).
The real bug is the DBmysql::escape() non-string bypass (fixed in 11.0.3 by
casting to string): a lowercase <deviceid> stays a SimpleXMLElement and
reaches the glpi_agents lookup unescaped. Rewrote to POST /Inventory with
<QUERY>get_params</QUERY> and a lowercase <deviceid>; error-based detection
(lone quote 500 vs benign 200 control) plus EXTRACTVALUE extraction in run().
Validated: fires 11.0.2 (leaks DB version), silent 11.0.3.
front/reservation.form.php gated the update action on the attacker-supplied
$_POST['users_id'] instead of the reservation's stored owner. A Self-Service
user (reservation right 1024 = RESERVEANITEM only, no UPDATE) sets users_id to
their own id and id to a victim's reservation, taking it over. Fixed in 10.0.19
via $rr->check($_POST['id'], UPDATE). Self-contained differential check (spawns
a throwaway probe, takes over its own seeded reservation, purges). Validated:
fires 10.0.17, silent 10.0.19.
Two members of the GLPI 10.0.19 access-control cluster:
- CVE-2025-53111: ajax/getMapPoint.php returned an item's name + GPS
  coordinates with no per-item READ check. A Self-Service probe reads a
  seeded Location it cannot view. Fix 10.0.19 adds $item->can(READ).
- CVE-2025-53112: ajax/unlockobject.php deleted any glpi_objectlocks row by
  id with no authorization. Non-mutating check probes a non-existent id and
  reads the ungated path (200) vs guarded (400 Not allowed) differential.
  Fix 10.0.19 adds $ol->can($id, PURGE).
Both validated: fire 10.0.17, silent 10.0.19.
GET /Form/Export checked only the global form READ right then exported every
id in ids[] with no per-form authorization, so a user scoped (by entity) away
from a form could still export its full structure. Fix in 11.0.7 adds a
per-form $form->can($id, READ) filter. Self-contained check provisions a child
entity, a form locked to it, a forms-READ profile and a scoped probe user,
then exports the out-of-scope form as the probe (with a nonexistent-id control)
and tears down. Validated: fires 11.0.6, silent 11.0.7.
front/link.send.php checked only the global link READ right then substituted
any item's fields ([SERIAL], [FIELD:x] ...) into the link content it returns,
with no per-item READ check. A links-only user (link READ, no Computer READ)
recovers a computer's serial. Fix 10.0.19 adds $item->can($id, READ). Check
provisions a link-only profile + probe user + a target computer + a placeholder
link, recovers the serial as the probe (nonexistent-id control), and cleans up.
Validated: fires 10.0.17, silent 10.0.19. Completes the 10.0.19 access-control
cluster (53111/53112/53113).
ajax/planning.php?action=edit_event_form echoed the request param url straight
into an <a href='...'> link. A crafted link opened by a logged-in central user
points at an attacker URL (phishing / open redirect). Fix 10.0.19 rebuilds the
href from $item->getLinkURL(), ignoring the param. Check probes whether a unique
marker url is reflected into the href. Validated: fires 10.0.17, silent 10.0.19.
ajax/webhook.php gates the file on config READ; three actions lacked the
per-action config UPDATE check until 11.0.7, so a config-READ user (Read-Only
profile) could invoke them:
- GHSA-4gr9-6x95-wfgv valide_cra_challenge: outbound GET to the webhook URL
  (low-priv SSRF; verified with a captured OOB GuzzleHttp callback).
- GHSA-9wh2-hfjr-jqhf resend: QueuedWebhook::sendById outbound delivery
  (low-priv SSRF; non-mutating check via a non-existent queued id).
- GHSA-mxf4-8mjr-3qh2 update_payload_template: canUpdateItem() -> can(id,UPDATE);
  config-READ user rewrites any webhook payload (effect verified).
Checks spawn a Read-Only probe (profile 8) and key on 403 AccessDenied (patched)
vs the action proceeding (vuln). No CVE IDs assigned; named by GHSA (precedent:
unserialize_order_plugin). Validated: fire 11.0.6, silent 11.0.7.
/front/rssfeed.form.php fetches the feed URL synchronously via Toolbox::isUrlSafe,
whose lax allowlist regex (<=10.0.18) accepted a backslash in the path
(http://host/x\token); curl normalises it and issues the outbound request (blind
SSRF). Fix 10.0.19 uses the strict Symfony UrlValidator grammar which rejects it.
Check seeds a backslash-path feed and a disallowed :port control: vulnerable when
the control is rejected but the backslash URL is accepted; patched rejects the
backslash ("not allowed by your administrator"). SSRF impact via -O ssrf_url with
a captured collaborator hit. Validated: fires 10.0.17, silent 10.0.19.
CommonITILCost::showForObject passed the cost comment to Html::showToolTip
unescaped (rendered |raw in the datatable), so a comment like
<img src=x onerror=...> on a ticket/change/problem cost executes for any viewer
of the Costs tab. Fix 11.0.7 wraps the comment in htmlescape(); 10.x is not
affected (global $_POST sanitiser). Check creates a ticket + cost with a marker
payload, renders the TicketCost tab, detects raw vs encoded. Validated: fires
11.0.6, silent 11.0.7.
The check was only a plugin presence + version gate, and the boolean run()
needs a valid agent_id. Added an unauthenticated, agent-less time-based check:
the setStatus deploy endpoint passes machineid into deviceid='<...>' unescaped
(plugin <= 1.5.0), so a SLEEP breakout delays the response with no agent. Uses
benign + SLEEP(0) baselines as negative controls. 1.5.1 sanitises the input.
Validated: fires on glpiinventory 1.5.0 (10.0.16, SLEEP(3) delta 3.0s), gated
when the plugin is absent.
… count)

The log-export glpi_logs SELECT has 10 columns on 10.x but 12 on 11.x, so the
hardcoded 10-column UNION mismatched and 500'd on 11.x, never returning the
canary (false negative). Resolve the column count from the detected version
(12 for 11.x, 10 for 10.x). Validated: fires 11.0.2 and 10.0.22, silent 11.0.7
and 10.0.25.
ajax/dashboard.php skipped both checkLoginUser() and the embed token check
when embed=1 and action is get_card/get_cards/get_dashboard_items, so an
unauthenticated request renders any built-in card and leaks instance counts.
Fix 10.0.8 calls Grid::checkToken() on the embed branch. Check: embed=1
get_card renders a bigNumber card (vuln) while the no-embed control is denied
(302 login). Validated: fires 10.0.1, 403/gated on 10.0.8+.
AuthLDAP::buildLdapFilter() concatenates the criterias value (all versions) and
the entity_filter param (11.0.x) into the LDAP search filter without
ldap_escape(), unlike the login flow. A user with IMPORTEXTAUTHUSERS (Hotliner+)
blind-extracts any LDAP attribute, including userPassword hashes, via a
response-size oracle and octetStringOrderingMatch binary comparison. check()
pins a real user and ANDs in (objectClass=top) vs (objectClass=ZZZZZ): a large
oracle delta with a near-zero fake-user control confirms the injection. run()
extracts attributes + the password hash. Credit unclej4ck, Albert. No patched
release yet (entity_filter raw-concat present through 11.0.7), so the behavioral
oracle gates. Validated: fires on 11.0.2 (entity_filter vector, delta ~11.7KB,
fake-user control 10).
Lock::showForItem() emits a per-item locked field's related-object name bare
inside the <a> tag (between the href quote and '>'), unescaped, on 10.0.4-10.0.24.
For an Item_OperatingSystem lock the OperatingSystem name lands there, so an OS
named "<m> onmouseover=alert(<m>) x=" injects an event handler. Fix 10.0.25 moves
the name after '>'. Check (Technician): inventory-push mints a payload-named OS +
a dynamic target, manual OS change auto-creates the lock, render the Locks tab,
detect the bare-in-tag breakout. Validated: fires 10.0.23, silent 10.0.25.
…lded)

The ` AND IF(cond,SLEEP,0)` connector in the sort[] injection is constant-folded
out of the ORDER BY by MariaDB, so SLEEP never executes (no timing delta on the
PHP-7.x-only vuln). A real second sort term `,IF(cond,SLEEP,0)` is evaluated per
row. Verified on 10.0.1 (PHP 7.4): TRUE probe sleeps, FALSE probe does not.
formcreator TextField::getRenderedHtml() returned the answer value raw (<=2.13.5),
so a text answer "><img src=x onerror=...> is echoed unescaped when staff open the
form answer (and in the ##FULLFORM## ticket). Fixed in 2.13.6 (Sanitizer::sanitize).
Check builds a public form over HTTP (form -> section_add -> question_add with the
text field's required range/regex array params, qid read from the question_add JSON),
submits the payload anonymously, renders the FormAnswer tab, and confirms RAW vs
escaped reflection. Validated: fires formcreator 2.13.5/GLPI 10.0.16, silent 2.13.6.
j4kuuu added 15 commits June 15, 2026 01:53
…ntial)

The check only verified the lost-password page exists + had form markers, so it
fired on every GLPI including patched (a true false positive). The 10.0.17 fix
injects sleep(rand(1,3)) into the handler before the DB lookup, so an INVALID
email now takes 1-3s; on a vulnerable build it returns in ms (no valid email or
SMTP needed). check() now times an invalid-email POST (median of 4) with a GET
network control: fast = oracle present, 1-3s = patched. Validated: 10.0.16 0.01s
fires, 10.0.17 1.52s silent.
53357: the reservation-takeover IDOR is real (DB-confirmed) but check() re-read
the form as the admin, where a non-owner's comment is not echoed, so it reported
not-vulnerable on the vuln instance. Re-read as the probe (new owner). Validated:
fires 10.0.17, silent 10.0.19.

26027: check read /front/agent.php (the datatable HTML-escapes the tag) so it
never saw the raw payload; the real stored-XSS sink is the agent main form tab
(htmlField {{value|raw}}). Also the scanner's long-lived session carries sticky
GLPI search state that hid the freshly-pushed agent, so resolution now runs a
deviceid-filtered search through a fresh authenticated session. Validated: fires
11.0.2 (3/3), silent 11.0.6.
…mes)

GLPI 11.x /ajax/fileupload.php 500s when re-uploading an existing filename
(PHP 8.4 upcount_name dedup), so the fixed-name seed failed on re-runs. Use a
unique filename per upload for the secret and decoy docs. Validated: fires on
11.0.2 across repeated runs without clearing tmp, silent on 11.0.3.
The module targeted /ajax/rulecriteriavalue.php?id= which has no SQL sink and
omits the ajax CSRF header, so it never fired. The real sink is RuleImportAsset
::handleFieldsCriteria() case 'uuid' (raw in a WHERE, fixed 10.0.18 with LOWER()
+getUUIDRestrictCriteria, GHSA-pcmc-xv3g-hjxv). But every path to it runs
Sanitizer::sanitize() on the uuid, so the quote is always escaped: a SLEEP
payload pushed via /front/inventory.php produces uuid='x\' OR SLEEP..' and never
delays on 10.0.17 (proven via MariaDB general_log, 8 payload variants). No
black-box differential exists, so check() is an honest version-gate stating the
limitation; run() is a forensic demo of the sanitized sink.
The module pushed the agent tag and read /front/agent.php; both are wrong (tag is
HTML-encoded at storage and the datatable escapes it). The real bug (GHSA-j8vv-
9f8m-r7jx, fix 50d9b54) is in Inventory\Asset\Device::handle(), which DB-escaped
component values (dbEscapeRecursive) instead of HTML-encoding them. A device
designation (inventory <NAME>, e.g. a CPU) is stored with angle brackets intact
and rendered raw as the component link text in the asset Components tab
(Item_Devices$1) -> stored XSS. Fix 10.0.21 uses Sanitizer::sanitize(). Check
pushes a CPU designation probe, resolves the asset via a fresh-session filtered
search, renders the Components tab, keys on the raw probe. Validated: fires
10.0.17, silent 10.0.22.
The dead host 10.255.255.1 now returns an immediate ICMP unreachable in the lab
network instead of hanging, so the time-based probe stopped firing. The SSRF is
genuinely live (confirmed via OOB: Guzzle instantiates and connects, cURL error
18 from the listener). Switched the dead host to 240.0.0.1 (reserved class-E,
SYN blackholed -> ~5s+ connect timeout) which hangs reliably where private/RFC5737
ranges fast-fail. Validated: 10.0.12 probe 12.0s vs benign 0.03s, 10.0.14 0.04s.
31061: add a benign-baseline guard (a non-injecting login must return fast, else
the target is just slow and the timing is not a clean signal), matching the 27098
pattern. The SQLi is real: auths_id=explode('-',auth)[1] flows raw into
getFromDBByCrit on the LDAP login branch (fix 10.0.2 = regex + int cast); the
quote survives the global Sanitizer (general_log confirmed), comment must be '#'.
Validated: fires 10.0.1 (LDAP configured), silent 10.0.2.

22248: keep the honest no-op, with the evidence-based root cause. Real vector is
the inventory parser (strip_tags fix 11.0.5), not path traversal. No black-box
differential: schema enum + is_a/is_subclass_of allowlists behave identically on
11.0.3 vuln and 11.0.6/7 patched (itemtype=Computer 200 both, GuzzleHttp/Foo<x>
400 both, 0 OOB, no timing delta); the GuzzleHttp SSRF trick is gated on 11.x.
Drop four modules that do not have a clean fire-on-vulnerable / silent-on-patched
differential, so the pushed set is 100% behaviorally validated:
- CVE-2023-42802: version-gate only (real bug has no black-box differential)
- CVE-2025-21619: version-gate only (uuid sink Sanitizer-escaped on every path)
- CVE-2026-22248: no-op (object-instantiation sinks gated, no public PoC)
- CVE-2024-27096: mis-attributed. check() injects via sort[0] (the 43813/31456
  addOrderBy sink), not its documented criteria[0][value] vector, so it cannot
  prove the 10.0.13 patch and is redundant with CVE-2024-31456.

Registry now 71 CVE/GHSA modules, each fires on a live vulnerable instance and
is silent on the patched one.
dev-swap completeness audit: every module must handle --run without crashing.
51446 was detection-only; add a run() that performs the wildcard auth-flow login
(<prefix>* + password) to demonstrate the injection. No behavior change to check().
The earlier version-gate was removed for not being behaviorally validated. The
real primitive is arbitrary file READ via source-path traversal:
Document::moveDocument builds the source as GLPI_TMP_DIR/$filename from the raw
_filename, so a Document filename "../_pictures/../_tmp/<staged>" escapes
files/_tmp and pulls a file into the doc store, served back by document.send.php.
Fix 10.0.10 rejects '/' or '\\' in the filename. Check stages a canary, adds the
traversal Document, downloads it, with a benign bare-filename control that must
round-trip on both. Destructive (opsec-unsafe), purges all probe docs. Validated:
fires 10.0.9 (canary served), silent 10.0.10 (guard, control still OK), 0 leaks.
…nageParams)

The removed version was mis-attributed: its check injected via /ajax/map.php
(the CVE-2024-31456 path), so it could not prove the 10.0.13 boundary. The real
CVE-2024-27096 vector is /ajax/search.php POST sort[] reaching Search::addOrderBy
via manageParams, fixed 10.0.13 by int-casting sort ($int_params=['sort']).
Distinct from 43813 (GET sort[], fixed 10.0.10) and 31456 (map.php, fixed 10.0.15).
PHP-7.4-only (loose == gate in constructSQL). Validated PHP-matched: vuln PHP7
10.0.11/10.0.12 fire, patched PHP7 10.0.15 silent (the int-cast, not PHP), PHP8
silent (loose-gate control). Per-row derived-table SLEEP oracle.
Arbitrary server-file read via the document source-path traversal: _filename[0]
climbs from files/_tmp to the target. Reuses the check()'s validated staging-free
traversal primitives (_add_document/_download/_purge). Target needs a GLPI-allowed
extension. Build-tested; the traversal primitives were live-validated in check().
# Conflicts:
#	glpwnme/exploits/implementations/cve_2026_26026.py
Merged upstream credited Zax and rated it 6.4 / 'SSTI'. Verified live on 11.0.2:
run(cmd=id) returns uid=33(www-data), so it is real code execution (upstream's
own run() calls shell_exec), not SSTI-only. But it requires Super-Admin (PR:H),
so 9.1 was too high. CVSS:3.1/AV:N/AC:L/PR:H/UI:N/S:U/C:H/I:H/A:H = 7.2. Keep both
authors (Zax found it, this fork hardened the check).
@Guilhem7

Copy link
Copy Markdown
Collaborator

Hey thank you very much for you PR ! 👍
But honestly I think I will not be able to review it there are too many files added and I cannot review all of this.

Just so you know, here are some improvements for this PR:

  • the purpose of glpwnme is to help pentester during assessment, given this, some vulnerabilities does not need to be added (blind SSRF for instance, reflected XSS, ...)
  • Some functions are already present in glpwnme (self.get and self.post which automatically manages csrf and url expansion /index.php --> https://my_target/glpi/index.php)
  • The @author within an exploit class represents the researcher who found the vulnerability and not the person who wrote the exploit.

Again thank you very much, some vulnerabilities you implemented looks really interesting and would be so great in glpwnme ! 🛩️

It is just that I think I will not have the time to review all of this 🥲

Anyway I think we could try to implements some interesting vulnerabilities you added, such as authent bypass with type juggling and improve check of some vulns as you highlighted !

@UncleJ4ck

Copy link
Copy Markdown
Author

Closing in favour of smaller, per-class PRs as you suggested. The modules from this PR are split into:

Each stands alone (a few in #18/#19/#21 share lowpriv.py). Thanks for the review notes.

@UncleJ4ck UncleJ4ck closed this Jun 17, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants