- Advisory: GHSA-23hj-xm9r-gwx6 · CVE-2026-75606
- Affected:
egroupware/egroupware< 26.7.20260710 and < 23.1.20260710 · Fixed: 26.7.20260710 / 23.1.20260710 - Severity: High · CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:L/A:L = 8.1 · CWE-89
- Status: publicly disclosed and fixed. Reported by Pig-Tail through coordinated disclosure.
The Nextmatch widget forwards the client-controlled col_filter map — keys as well as values —
into the storage layer. In Base::search(), a filter whose value is the special string !''
takes a branch that concatenates the filter key directly into the SQL WHERE clause.
This is the sibling of GHSA-rvxj-7f72-mhrx
(Nextmatch col_filter type juggling). That fix strips only integer keys; this vector rides a
string key and was left uncovered by it.
api/src/Storage/Base.php:876-895 — the filter loop, !'' branch at line 891:
foreach($data2db_filter as $col => $val) {
if ($val !== '') {
if (!is_int($col) && ($c = array_search($col,$this->db_cols))) { $col = $this->table_name.'.'.$c; }
if (is_int($col)) { $db_filter[] = $val; }
elseif ($val === "!''") { $db_filter[] = $col." != ''"; } // <-- raw key
else { $db_filter[$col] = $val; }
}
}For a key that is not a real column, array_search fails, so $col stays as the attacker's raw
string; the !'' branch appends "<payload> != ''" under an integer array key. Then
api/src/Db.php:1772:
elseif (is_int($key) && $use_key===True) { … $values[] = $data; } // verbatim: no quote, no name_quoteemits it unchanged — and the string-key "unknown column" InvalidSql throw is bypassed precisely
because the key is now an integer. The payload lands in the query unescaped.
- Any authenticated non-admin session; default configuration. Open any list backed by the
default storage
get_rows— resources, timesheet, or anyso_sqlapp that does not override it. Nextmatch::ajax_get_rows($id,$range,$filters,…)—filters['col_filter']is attacker JSON. The guards atNextmatch.php:413and:1365strip only integer keys, so a string key survives. (order/sort/searchare separately whitelisted and are not the vector.)- The app's
get_rowsleaves unknowncol_filterkeys untouched (resources_bo.inc.php:187merges intocol_filter, never rebuilds it) and callsBase::get_rows:1653, which passes$query['col_filter']verbatim tosearch(). - Filter loop →
!''branch → raw key concatenation under an integer key. db->select(… $where …)→column_data_implode(' AND ', $where, True, …)→ verbatim emit.
UNION-based extraction of arbitrary tables — notably egw_accounts.account_pwd (password hashes)
and session/credential tables, i.e. full authentication compromise. Blind/boolean and error-based
techniques apply equally. The ADOdb/mysqli driver does not permit stacked queries, so the sink stays
a SELECT … WHERE and direct integrity/availability impact is capped — which is why I/A are scored
Low rather than High.
poc/sqli_poc.php reproduces the Nextmatch int-key strip, the Base::search() filter loop and the
column_data_implode integer-key branch verbatim, and prints the SQL they generate. It is fully
offline — no database is contacted and no EGroupware installation is needed.
php poc/sqli_poc.php # PHP 8.0+Input col_filter:
{ "0=1) UNION SELECT account_lid,account_pwd,3,4,5,6,7 FROM egw_accounts -- ": "!''" }Generated SQL:
SELECT ts_id,ts_title,ts_owner FROM egw_timesheet
WHERE (0=1) UNION SELECT account_lid,account_pwd,3,4,5,6,7 FROM egw_accounts -- != '')The injected UNION reaches the WHERE clause unescaped and the trailing -- comments out the
appended != ''), leaving a syntactically valid query. Confirming the UNION read end-to-end
additionally requires a running instance with a populated egw_accounts; the harness deliberately
stops at the generated statement.
Validate $col in the !'' branch against $this->db_cols / $this->table_def and reject or
name_quote it — never concatenate a raw key. At the Nextmatch boundary, validate col_filter
keys against the widget's known columns rather than only stripping integer keys, and add a
schema check to the integer-keyed branch of column_data_implode.