Hăm hở tái hiện lại CVE-2022-0754 và cái kết
This post hasn't been updated for 2 years
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:
Ồ, 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
:
và truy cập vào: https://localhost:8443/index.php là đã hiện lên giao diện login rồi:
Login bằng account user:bitnami
. Source code ở /bitnami/suitecrm
.
Debug
Để debug code PHP thì có 2 cách:
- hiện đại chuyên nghiệp dùng X-Debug và đặt break point.
- 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).
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"
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ố module
và action
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
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 = [
'"' => '&',
'"' => '"',
"'" => ''',
'<' => '<',
'>' => '>',
'`' => '`'
];
$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'
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:
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 retrieve
ở data/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
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
:
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