Add behaviorally-validated GLPI CVE/GHSA detection modules#12
Add behaviorally-validated GLPI CVE/GHSA detection modules#12UncleJ4ck wants to merge 52 commits into
Conversation
…use session user id)
…n_process_update guard, not page accessibility)
…w-priv user; add low-priv helper)
…l vectors: OOB SSRF, object-instantiation RCE, API KB visibility)
…per was optimized away)
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.
…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.
…ist entry missed)
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).
|
Hey thank you very much for you PR ! 👍 Just so you know, here are some improvements for this PR:
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 ! |
|
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 |
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-43416previously 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 addssleep(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-41326andCVE-2024-37149shipped without acheck()and crashed on--check. Both now have behavioral checks.CVE-2023-43813's ORDER BY payload was constant-folded by MariaDB, so theSLEEPnever executed. The connector is now a real second sort expression.CVE-2024-27096targetedmap.php, which isCVE-2024-31456's sink. It now targets its realsearch.phpmanageParamspath and proves the 10.0.13 fix.Merge note: one conflict, in
CVE-2026-26026. I kept the@author Zaxcredit and adjusted@cvssfrom 6.4 to 7.2.GHSA-2c98-648q-h27hscores it 7.2 and 9.1, and the module'srun()achieves code execution viashell_exec(verified on 11.0.2,run(cmd=id)returneduid=33(www-data)), so the SSTI-only classification understated it. The 11.0.6 fix is as the module describes:QuestionTypeDropdownconcatenates the parent render afterrenderFromStringTemplate(), breaking the double compilation.Testing: for each module, deploy the release immediately before the fix and the first fixed release, run
--checkagainst 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 afinally. 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.