+15

Hăm hở tái hiện lại CVE-2022-0754 và cái kết

Intro

Vào một ngày đẹp trời, trong lúc lướt qua danh sách tweets thì mình bắt gặp cái này:

image.png

Ồ, một lỗi SQLi với số điểm rất cao, target là: https://github.com/salesagility/SuiteCRM với gần 2.9k star trên Github:

SuiteCRM is the award-winning open-source, enterprise-ready Customer Relationship Management (CRM) software application.

chắc là có gì hay ho đây, vậy là mình bắt tay vào thử tìm cách tái hiện.

CVE Details

Trước hết là tìm thông tin về lỗi này thì có ngay kết quả ở: https://huntr.dev/bounties/8afb7991-c6ed-42d9-bd9b-1cc83418df88/

In SuiteCRM v7.12.4, a malicious user can inject SQL query in order to affect the execution of predefined SQL commands impacting database leakage.

Một chút về huntr.dev, đây là platform bug bounty nhưng dành riêng cho các repo open source, các researcher cũng sẽ tìm các lỗ hổng, submit và được maintainer của repo triage và thưởng các fix bounty cho reporter. Đồng thời huntr.dev cũng sẽ là bên đánh CVE cho lỗi luôn. Lỗi này được đánh là CVE-2022-0754.

We fund open source security. We pay security researchers for finding vulnerabilities in any GitHub repository and maintainers for fixing them.

huntr.dev

Đọc qua report thì cũng không thấy reporter đính kèm PoC, ta chỉ biết là payload truyền vào ở $_POST['record'].

Setup

Để setup debug thì một trong những lựa chọn mì ăn liền và đỡ tốn công nhất đấy chính là sử dụng docker. Rất may mắn là SuiteCRM được bitnami đóng gói thành docker sẵn sàng, nên với file docker-compose.yml dưới đây, mình đã nhanh chóng có môi trường để debug. Ở đây mình chọn docker tag là 7.12.4, là phiên bản trước khi fix lỗi.

version: '2'
services:
  mariadb:
    image: docker.io/bitnami/mariadb:10.3
    environment:
      # ALLOW_EMPTY_PASSWORD is recommended only for development.
      - ALLOW_EMPTY_PASSWORD=yes
      - MARIADB_USER=bn_suitecrm
      - MARIADB_DATABASE=bitnami_suitecrm
    volumes:
      - 'mariadb_data:/bitnami/mariadb'
  suitecrm:
    image: docker.io/bitnami/suitecrm:7.12.4-debian-10-r17
    ports:
      - '8880:8080'
      - '8443:8443'
    environment:
      - SUITECRM_DATABASE_HOST=mariadb
      - SUITECRM_DATABASE_PORT_NUMBER=3306
      - SUITECRM_DATABASE_USER=bn_suitecrm
      - SUITECRM_DATABASE_NAME=bitnami_suitecrm
      # ALLOW_EMPTY_PASSWORD is recommended only for development.
      - ALLOW_EMPTY_PASSWORD=yes
    volumes:
      - 'suitecrm_data:/bitnami/suitecrm'
    depends_on:
      - mariadb
volumes:
  mariadb_data:
    driver: local
  suitecrm_data:
    driver: local

Chạy docker-compose up:

image.png

và truy cập vào: https://localhost:8443/index.php là đã hiện lên giao diện login rồi:

image.png

Login bằng account user:bitnami. Source code ở /bitnami/suitecrm.

Debug

Để debug code PHP thì có 2 cách:

  1. hiện đại chuyên nghiệp dùng X-Debug và đặt break point.
  2. Thủ công old-school bằng đặt console log, var_dump, die,...

Mình chọn cách 2 cho nó...nhanh. Vậy làm sao để có thể debug trong docker, đồng thời là có thể search source code luôn từ trong docker, thay vì dùng grep hoặc phải clone lại? VSCode gần đây đã có hỗ trợ extension Remote - Containers (hiện đang là bản Preview), cho phép chúng ta cài đặt một VSCode server và từ đó remote access vào trong docker, chỉnh sửa file, source code dễ dàng hơn rất nhiều (tương tự như remote vào WSL từ Windows hoặc remote SSH lên server vậy).

image.png

Cài đặt: từ https://marketplace.visualstudio.com/items?itemName=ms-vscode-remote.remote-containers và chọn "Attach to Running Container..." và thế là "I'm in" 🕵

image.png

Vulnerable Code

OK, cùng xem lại đoạn code lỗi, đúng là ở dòng 62, input của người dùng được truyền thẳng vào câu truy vấn, như những gì report mô tả.

Analysis

How to call a function?

Sau khi đăng nhập bằng tài khoản admin user:bitnami, mình nhận thấy tất cả chức năng đều gọi thông qua file index.php truyền vào tham số moduleaction VD: URL sau khi login là: https://localhost:8443/index.php?module=Home&action=index

Do đó, với lỗi nằm ở đường dẫn: modules/ProspectLists/Duplicate.php thì tương ứng URL sẽ là

&module=ProspectLists&action=Duplicate

Trước hết, thử với action index đưa ta đến với chức năng bị lỗi: https://localhost:8443/index.php?module=ProspectLists&action=index

image.png

Good Payload

Sau khi tạo thử vài Target List và thực hiện duplicate thì với một payload thông thường

POST /index.php HTTP/1.1
Host: localhost:8443
Cookie: PHPSESSID=ol7snc52ilfoe5du9r7kr2dulb; sugar_user_theme=SuiteP; ProspectLists_divs=ProspectLists_prospects_v%3Dfalse%23undefined%3D%23
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:99.0) Gecko/20100101 Firefox/99.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8
Accept-Language: en-US,en;q=0.5
Accept-Encoding: gzip, deflate
Content-Type: application/x-www-form-urlencoded
Content-Length: 67
Origin: https://localhost:8443
Dnt: 1
Referer: https://localhost:8443/index.php
Upgrade-Insecure-Requests: 1
Sec-Fetch-Dest: document
Sec-Fetch-Mode: navigate
Sec-Fetch-Site: same-origin
Sec-Fetch-User: ?1
X-Pwnfox-Color: green
Te: trailers
Connection: close

module=ProspectLists&record=12121&isDuplicate=true&action=Duplicate

thì response là:

Location: index.php?action=DetailView&module=ProspectLists&record=49ca2900-f0e5-9c46-2b68-622816080bf6

so sánh với nội dung file Duplicate.php ta có thể confirm là trigger vào đúng file cần tìm.

Evil Payload

Thử truyền vào abc' để escape khỏi SQL

$query = "select * from prospect_lists_prospects where prospect_list_id = '".$_POST['record']."'";

kết quả payload bị block luôn:

HTTP/1.1 200 OK
Date: Wed, 09 Mar 2022 02:56:41 GMT
Server: Apache/2.4.52 (Unix) OpenSSL/1.1.1d PHP/7.4.28
X-Content-Type-Options: nosniff
Content-Length: 65
Connection: close
Content-Type: text/html; charset=UTF-8

Bad data passed in; <a href="http://localhost">Return to Home</a>

Why? why? why? Sau một đọc code và debug mình cũng tìm thấy nguyên nhân:

First Block

Từ error Bad data passed ta truy ngược ra flow như sau:

Trong file index.php có đoạn:

require_once 'include/entryPoint.php';

Ở file include/entryPoint.php :

///////////////////////////////////////////////////////////////////////////////
////	DATA SECURITY MEASURES
require_once 'include/utils.php';
require_once 'include/clean.php';
clean_special_arguments();
clean_incoming_data();
////	END DATA SECURITY MEASURES
///////////////////////////////////////////////////////////////////////////////

Chú ý đến hàm này clean_incoming_data nằm trong file include/utils.php

function clean_incoming_data()
{
    global $sugar_config;
    global $RAW_REQUEST;

    $RAW_REQUEST = $_REQUEST;

    $req = array_map('securexss', $_REQUEST);
    $post = array_map('securexss', $_POST);
    $get = array_map('securexss', $_GET);
	...
}

Tức là tất cả các param truyền vào sẽ bị filter xss thông qua hàm securexss

function securexss($uncleanString)
{
    if (is_array($uncleanString)) {
        $new = [];
        foreach ($uncleanString as $key => $val) {
            $new[$key] = securexss($val);
        }

        return $new;
    }

    static $xss_cleanup = [
        '&quot;' => '&#38;',
        '"' => '&quot;',
        "'" => '&#039;',
        '<' => '&lt;',
        '>' => '&gt;',
        '`' => '&#96;'
    ];

    $uncleanString = preg_replace(array('/javascript:/i', '/\0/', '/javascript:/i'),
        array('java script:', '', 'java script:'), $uncleanString);

    $partialString = str_replace(array_keys($xss_cleanup), $xss_cleanup, $uncleanString);
    error_log("\npartialString = " . $partialString . "\n", 3, "/tmp/my-errors.log");
    $antiXss = new AntiXSS();
    $antiXss->removeEvilAttributes(['style']);

    return $antiXss->xss_clean($partialString);
}

Qua hàm này thì payload của ta sẽ thay đổi

abc' -> abc&#039;

Vẫn ở trong hàm clean_incoming_data có đoạn:

    if (isset($_REQUEST['record'])) {
        clean_string($_REQUEST['record'], 'STANDARDSPACE');
    }

Đoạn này được thêm vào từ 6 năm trước, nghĩa là đối với tham số record thì còn bị đi qua một bước filter nữa:

image.png

Xem tiếp trong hàm clean_string:

function clean_string($str, $filter = 'STANDARD', $dieOnBadData = true)
{
    global $sugar_config;

    $filters = array(
        'STANDARD' => '#[^A-Z0-9\-_\.\@]#i',
        'STANDARDSPACE' => '#[^A-Z0-9\-_\.\@\ ]#i',
        'FILE' => '#[^A-Z0-9\-_\.]#i',
        'NUMBER' => '#[^0-9\-]#i',
        'SQL_COLUMN_LIST' => '#[^A-Z0-9\(\),_\.]#i',
        'PATH_NO_URL' => '#://#i',
        'SAFED_GET' => '#[^A-Z0-9\@\=\&\?\.\/\-_~+]#i', /* range of allowed characters in a GET string */
        'UNIFIED_SEARCH' => '#[\\x00]#', /* cn: bug 3356 & 9236 - MBCS search strings */
        'AUTO_INCREMENT' => '#[^0-9\-,\ ]#i',
        'ALPHANUM' => '#[^A-Z0-9\-]#i',
    );

    if (preg_match($filters[$filter], $str)) {
	    error_log("\n-- clean_string -- \nfilter = " . $filter . "\n data=" . $str . "\n", 3, "/tmp/my-errors.log");
        if (isset($GLOBALS['log']) && is_object($GLOBALS['log'])) {
            $GLOBALS['log']->fatal("SECURITY[$filter]: bad data passed in; string: {$str}");
        }
        if ($dieOnBadData) {
            die("Bad data passed in; <a href=\"{$sugar_config['site_url']}\">Return to Home</a>");
        }

        return false;
    }
    return $str;
}

như vậy record sẽ được filter qua regex: '#[^A-Z0-9\-_\.\@\ ]#i' nghĩa là block hết các ký tự đặc biệt trừ ký tự alphabet, số và _, -, ., @, . Với filter này thì hiện tại mình chưa nghĩ ra cách nào có thể trigger được SQLi cả 😰

Another Block

Kể cả bằng 1 cách thần kỳ nào đó, chúng ta pass qua được các bước filter và đến được đoạn:

$focus = BeanFactory::newBean('ProspectLists');

$focus->retrieve($_POST['record']);

tiếp tục flow ở file: data/BeanFactory.php

    public static function newBean($module)
    {
        return self::getBean($module);
    }
	
     *   
     * @return SugarBean|bool
     */
    public static function getBean($module, $id = null, $params = [], $deleted = true)

Túm lại là được extend từ SugarBean

xem tiếp hàm retrievedata/SugarBean.php

    public function retrieve($id = -1, $encode = true, $deleted = true)
    {
        $custom_logic_arguments['id'] = $id;
        $this->call_custom_logic('before_retrieve', $custom_logic_arguments);

        if ($id == -1) {
            $id = $this->id;
        }
        $custom_join = $this->getCustomJoin();

        $query = "SELECT $this->table_name.*" . $custom_join['select'] . " FROM $this->table_name ";

        $query .= $custom_join['join'];
        $query .= " WHERE $this->table_name.id = " . $this->db->quoted($id);

nên $id truyền vào đã được quote trước khi SELECT. Thêm một thử thách khó khăn nữa.

Conclusion?

The $_POST['record'] [1] parameter is controllable by a user and it is concatenated into SQL query [2] without validating them.

là đúng (một phần) nhưng thực tế thì không có cách này để trigger được SQLi cả, lỗi này có thể coi như là không tồn tại?

Thế nhưng dev của SuiteCRM vẫn confirmed và fix bug, khiến mình vẫn khá hoang mang đến tận bây giờ, ko biết phải là bug hay không 😅. Thôi thì cẩn thận thì không bao giờ là thừa 😄

image.png

Bonus

Đọc code SuiteCRM mình thấy hệ thống thực sự đã có khá nhiều cơ chế để chống lỗi, tuy nhiên nếu không phải param record thì liệu có cách nào trigger SQLi không, thì mình đã tìm thấy câu trả lời ở một CVE khác: CVE-2021-45041 (lần này thì có PoC sqlmap đầy đủ luôn, cực kỳ uy tín 😃)

Link PoC: https://github.com/manuelz120/CVE-2021-45041

Lần này param đã khác, resource_id nên sẽ không còn bị filter STANDARDSPACE nữa, số ký tự khả dụng đã tăng lên trông thấy nhưng sử dụng single-quote để escape thì vẫn là bất khả thi (vì HTML-Entity encoding như ở hàm securexss ở trên). Tuy nhiên, trong câu truy vấn chúng ta được chèn payload nhiều lần (multiple injection points) nên tác giả đã nghĩ đến việc sử dụng backslash \ ở cuối để biến kí tự ' trở thành kí tự thường trong câu truy vấn, từ đó để escape và inject ở biến start_date:

image.png

Bình thường câu truy vấn sẽ kiểu kiểu như sau:

SELECT * FROM xxx WHERE (project_task.assigned_user_id = 'AAA' AND ( ( project_task.date_start BETWEEN 'BBB'  AND 'CCC' ) OR ( project_task.date_finish BETWEEN 'BBB' AND 'CCC' ) OR ( 'BBB' BETWEEN project_task.date_start  AND project_task.date_finish ) OR ( 'CCC' BETWEEN project_task.date_start AND project_task.date_finish ) ) AND (project_id is not null AND project_id <> '') ...

Nhưng khi resource_id có giá trị là test\:

SELECT * FROM xxx WHERE (project_task.assigned_user_id = 'test\' AND ( ( project_task.date_start BETWEEN ') UNION SELECT 0, 1, 2, 3, 4, (SELECT user_hash from users limit 1), 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 from dual; #'  AND 'CCC' ) OR ( project_task.date_finish BETWEEN 'BBB' AND 'CCC' ) OR ( 'BBB' BETWEEN project_task.date_start  AND project_task.date_finish ) OR ( 'CCC' BETWEEN project_task.date_start AND project_task.date_finish ) ) AND (project_id is not null AND project_id <> '') ...

vậy là escape và trigger thành công 👏

End

Ai đó nói rằng: "Trong cuộc vui ba người thì sẽ luôn có một người buồn..." quả là không sai...🥲


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í