這陣子在 Front-End Developers Taiwan 臉書社團上有一系列關於 Tailwind CSS 的討論,起因是另一篇已經刪除的貼文,那篇貼文我有看過,因為怕原文內容跟記憶內容有落差,因此這邊就不講我記憶中的原文大概是在寫什麼了,畢竟這也不是這篇所關注的重點。

總之呢,那篇貼文引起了臉書上前端社團的熱烈討論,在短短兩三天內迅速多出許多討論相關技術的文章。

而有許多人在討論的議題,其實比起 Tailwind CSS 這個工具,更多的是在討論 Atomic CSS 這個概念本身。

閱讀更多

原本想要寫得詳細一點再 po 的,但我發現如果要這樣的話,可能要過很久才會 po,所以還是趕快先寫一篇簡短版的。

這次寫的是以下四題,都是 web:

  1. Fancy Notes
  2. Dumb Forum
  3. LESN
  4. ptMD

先記幾個 keyword 以後比較容易找:

  1. 長度擴充攻擊(Length extension attack)
  2. SSTI
  3. mutation XSS <svg><style>
  4. <meta name="referrer" content="unsafe-url" />
  5. <meta http-equiv="refresh" content="3;url">
  6. Puppeteer 的 click 行為是抓取元素位置再點擊座標

閱讀更多

前陣子在看一些 WordPress Plugin 的東西,發現是個滿好練習的地方,因為那邊 plugin 數量很多,而且每一個都有原始碼可以看,想要黑箱白箱都可以,然後安裝也很方便。

這篇來講一下前陣子找到的一個洞,用的是最基本而且經典的攻擊手法,檔案上傳導致的 RCE。

漏洞編號:CVE-2022-27862
WordPress VikBooking Hotel Booking Engine & PMS plugin <= 1.5.3 - Arbitrary File Upload leading to RCE

VikBooking 簡介與漏洞細節

VikBooking 是個 WordPress 的訂房外掛,官網的 demo 長這樣:

booking page

就跟其他的訂房外掛其實沒有什麼區別,完成訂房以後管理員在 WordPress 後台可以管理訂單,而消費者也會收到一封信,可以透過信件中提供的網址來管理自己的訂房:

booking page customer

雖然說 UI 上看起來沒什麼東西,但既然我們有了原始碼,就可以透過白箱測試的方式去看一下裡面的實作為何。

主要的操作跟邏輯都放在 site/controller.php 當中,裡面每一個 function 基本上都對應到一個 action,其中我發現了一個方法叫做 storesignature,程式碼如下:

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
public function storesignature()
{
$sid = VikRequest::getString('sid', '', 'request');
$ts = VikRequest::getString('ts', '', 'request');
$psignature = VikRequest::getString('signature', '', 'request', VIKREQUEST_ALLOWRAW);
$ppad_width = VikRequest::getInt('pad_width', '', 'request');
$ppad_ratio = VikRequest::getInt('pad_ratio', '', 'request');
$pitemid = VikRequest::getInt('Itemid', '', 'request');
$ptmpl = VikRequest::getString('tmpl', '', 'request');
$dbo = JFactory::getDBO();
$mainframe = JFactory::getApplication();
$q = "SELECT * FROM `#__vikbooking_orders` WHERE `ts`=" . $dbo->quote($ts) . " AND `sid`=" . $dbo->quote($sid) . " AND `status`='confirmed';";
$dbo->setQuery($q);
$dbo->execute();
if ($dbo->getNumRows() < 1) {
VikError::raiseWarning('', 'Booking not found');
$mainframe->redirect('index.php');
exit;
}
$row = $dbo->loadAssoc();
$tonight = mktime(23, 59, 59, date('n'), date('j'), date('Y'));
if ($tonight > $row['checkout']) {
VikError::raiseWarning('', 'Check-out date is in the past');
$mainframe->redirect('index.php');
exit;
}
$customer = array();
$q = "SELECT `c`.*,`co`.`idorder`,`co`.`signature`,`co`.`pax_data`,`co`.`comments` FROM `#__vikbooking_customers` AS `c` LEFT JOIN `#__vikbooking_customers_orders` `co` ON `c`.`id`=`co`.`idcustomer` WHERE `co`.`idorder`=".(int)$row['id'].";";
$dbo->setQuery($q);
$dbo->execute();
if ($dbo->getNumRows() > 0) {
$customer = $dbo->loadAssoc();
}
if (!(count($customer) > 0)) {
VikError::raiseWarning('', 'Customer not found');
$mainframe->redirect('index.php');
exit;
}
//check if the signature has been submitted
$signature_data = '';
$cont_type = '';
if (!empty($psignature)) {
//check whether the format is accepted
if (strpos($psignature, 'image/png') !== false || strpos($psignature, 'image/jpeg') !== false || strpos($psignature, 'image/svg') !== false) {
$parts = explode(';base64,', $psignature);
$cont_type_parts = explode('image/', $parts[0]);
$cont_type = $cont_type_parts[1];
if (!empty($parts[1])) {
$signature_data = base64_decode($parts[1]);
}
}
}
$ret_link = JRoute::rewrite('index.php?option=com_vikbooking&task=signature&sid='.$row['sid'].'&ts='.$row['ts'].(!empty($pitemid) ? '&Itemid='.$pitemid : '').($ptmpl == 'component' ? '&tmpl=component' : ''), false);
if (empty($signature_data)) {
VikError::raiseWarning('', JText::translate('VBOSIGNATUREISEMPTY'));
$mainframe->redirect($ret_link);
exit;
}
//write file
$sign_fname = $row['id'].'_'.$row['sid'].'_'.$customer['id'].'.'.$cont_type;
$filepath = VBO_ADMIN_PATH . DIRECTORY_SEPARATOR . 'resources' . DIRECTORY_SEPARATOR . 'idscans' . DIRECTORY_SEPARATOR . $sign_fname;
$fp = fopen($filepath, 'w+');
$bytes = fwrite($fp, $signature_data);
fclose($fp);
if ($bytes !== false && $bytes > 0) {
//update the signature in the DB
$q = "UPDATE `#__vikbooking_customers_orders` SET `signature`=".$dbo->quote($sign_fname)." WHERE `idorder`=".(int)$row['id'].";";
$dbo->setQuery($q);
$dbo->execute();
$mainframe->enqueueMessage(JText::translate('VBOSIGNATURETHANKS'));
//resize image for screens with high resolution
if ($ppad_ratio > 1) {
$new_width = floor(($ppad_width / 2));
$creativik = new vikResizer();
$creativik->proportionalImage($filepath, $filepath, $new_width, $new_width);
}
//
} else {
VikError::raiseWarning('', JText::translate('VBOERRSTORESIGNFILE'));
}
$mainframe->redirect($ret_link);
exit;
}

從 function name 跟程式碼可以推測出應該是一個上傳簽名檔案的功能,而檔案的內容會先 base64 過,所以程式碼中 decode 回 binary 然後寫進檔案,核心的程式碼如下:

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
$psignature = VikRequest::getString('signature', '', 'request', VIKREQUEST_ALLOWRAW);

//check if the signature has been submitted
$signature_data = '';
$cont_type = '';

if (!empty($psignature)) {
//check whether the format is accepted
if (strpos($psignature, 'image/png') !== false || strpos($psignature, 'image/jpeg') !== false || strpos($psignature, 'image/svg') !== false) {
$parts = explode(';base64,', $psignature);
$cont_type_parts = explode('image/', $parts[0]);
$cont_type = $cont_type_parts[1];
if (!empty($parts[1])) {
$signature_data = base64_decode($parts[1]);
}
}
}
$ret_link = JRoute::rewrite('index.php?option=com_vikbooking&task=signature&sid='.$row['sid'].'&ts='.$row['ts'].(!empty($pitemid) ? '&Itemid='.$pitemid : '').($ptmpl == 'component' ? '&tmpl=component' : ''), false);
if (empty($signature_data)) {
VikError::raiseWarning('', JText::translate('VBOSIGNATUREISEMPTY'));
$mainframe->redirect($ret_link);
exit;
}
$sign_fname = $row['id'].'_'.$row['sid'].'_'.$customer['id'].'.'.$cont_type;
$filepath = VBO_ADMIN_PATH . DIRECTORY_SEPARATOR . 'resources' . DIRECTORY_SEPARATOR . 'idscans' . DIRECTORY_SEPARATOR . $sign_fname;
$fp = fopen($filepath, 'w+');
$bytes = fwrite($fp, $signature_data);
fclose($fp);

從最後一段可以看出寫入檔案的內容為 $signature_data,路徑為 VBO_ADMIN_PATH . DIRECTORY_SEPARATOR . 'resources' . DIRECTORY_SEPARATOR . 'idscans' . DIRECTORY_SEPARATOR . $sign_fname,如果我們可以控制 $signature_data$sign_fname,就有了一個任意寫檔的漏洞,這些變數的值如下:

1
2
3
4
5
6
7
8
9
if (strpos($psignature, 'image/png') !== false || strpos($psignature, 'image/jpeg') !== false || strpos($psignature, 'image/svg') !== false) {
$parts = explode(';base64,', $psignature);
$cont_type_parts = explode('image/', $parts[0]);
$cont_type = $cont_type_parts[1];
if (!empty($parts[1])) {
$signature_data = base64_decode($parts[1]);
}
}
$sign_fname = $row['id'].'_'.$row['sid'].'_'.$customer['id'].'.'.$cont_type;

一個正常的 $psignature 大概是長這樣:data:image/png;base64,image_content

這邊先檢查 $psignature 有沒有指定的 content type,有的話用 ;base64, 去做字串切割,切完的 parts 會變成:

1
2
parts[0] = 'data:image/png';
parts[1] = image_content;

然後再把 parts[0]image/ 來切,拿到的第二段資料(以上面的例子來說,會拿到 png)就是 content type,而 parts[1] 則是直接做 base64 decode 後做為檔案內容寫入。

檔名 $sign_fname 的部分則是一些 id 最後加上剛剛得出的 content type。

從上面的邏輯可看出檔案內容基本上可以隨意控制,而檔名的部分也可以輕鬆繞過檢查,像這樣:

1
image/png/../../../../shell.php;base64,web_shell

有包含 image/png 所以檢查會過,切完之後 parts[0] 變成 image/png/../../../../shell.php,最後拿到的 content type 為 png/../../../../shell.php,拼接完的檔名會像這樣:id_sid_cid.png/../../../../shell.php,雖然說這檔名看起來很不合理,是檔案之後再接 ../,不過這在 PHP 中是沒問題的,可以看以下範例:

1
2
3
4
5
6
<?php
$filepath = 'not_exist.php/../poc.php';
$fp = fopen($filepath, 'w+');
$bytes = fwrite($fp, 'abc');
fclose($fp);
?>

像上面這樣的程式碼,最後還是會把內容寫入同個目錄底下的 poc.php

有了任意寫檔的漏洞以後,寫入一個 web shell 就 RCE 了,結果像是這樣:

rce

修復方式

Vikbooking 在 1.5.4 版修復了這個漏洞,把拿出資料以及 content type 的程式碼改為下面這一段:

1
2
3
4
5
6
7
8
9
10
11
12
if (!empty($psignature)) {
/**
* Implemented safe filtering of base64-encoded signature image
* to obtain content and file extension.
*
* @since 1.15.1 (J) - 1.5.4 (WP)
*/
if (preg_match("/^data:image\/(png|jpe?g|svg);base64,([A-Za-z0-9\/=+]+)$/", $psignature, $safe_match)) {
$signature_data = base64_decode($safe_match[2]);
$cont_type = $safe_match[1];
}
}

這邊改用正則來處理以後,確保了 match 到的 content type 只會是圖片的副檔名,在沒辦法控制檔名中其他參數的狀況下,就無法寫檔案到任意位置了。

結語

只能說在做這種讓使用者上傳檔案的功能時都必須特別小心,這種功能特別容易出事,例如說:

  1. 檔名沒過濾好,上傳 php 可以 web shell,上傳 HTML 就是 XSS
  2. 路徑沒過濾好,可以上傳到任意位置
  3. 不知道解壓縮時有可能會碰到 zip slip

總之呢,未來在實作類似功能時記得特別注意這些問題,避免寫出有漏洞的程式碼。

這次的比賽我第一天有事沒辦法參加,第二天參與時發現 web 的題目被隊友解的差不多了,所以有滿多題目沒去看的。

因為我滿愛 JavaScript 跟 XS-leak,所以這篇只會記兩題我最有興趣的:

  1. web/Sustenance
  2. misc/CaaSio PSE

(之後有機會再補另一題 DOMPurify + marked bypass 的 XSS)

閱讀更多

如果你想要在網頁上產生一個新的 window,大概就只有兩個選擇,一個是利用 iframeembedobject 這些標籤將資源嵌入在同個頁面上,而另一個選擇則是使用 window.open 新開一個視窗。

身為前端開發者,我相信大家對這些都不陌生,可能有用過 iframe 嵌入第三方的網頁,或是嵌入一些 widget,也有用過 window.open 開啟新的視窗,並透過 window.opener 跟原來的視窗溝通。

但站在資安的角度來看,其實 iframe 有不少好玩的東西,無論是現實世界或是在 CTF 內都經常出現,因此我想透過這篇記錄近期學到的一些特性。

閱讀更多

Amelia 是一個由 TMS 公司所開發的 WordPress 外掛,能夠輕鬆幫你的 WordPress 網站加上預約系統的功能,例如說診所、理髮廳或是家教等等,都很適合使用這個外掛來架一個簡單的預約系統。根據 WordPress 官方的統計,大約有 40,000 個網站都安裝了這個 plugin。

在三月初的時候我針對 Amelia 這套系統的原始碼做了一些研究,找到了三個都是敏感資訊洩露的漏洞:

  • CVE-2022-0720 Amelia < 1.0.47 - Customer+ Arbitrary Appointments Update and Sensitive Data Disclosure (CVSS 6.3)
  • CVE-2022-0825 Amelia < 1.0.49 - Customer+ Arbitrary Appointments Status Update (CVSS 6.3)
  • CVE-2022-0837 Amelia < 1.0.48 - Customer+ SMS Service Abuse and Sensitive Data Disclosure (CVSS 5.4)

如果被攻擊者利用這些漏洞,可以取得所有消費者的資料,包括姓名、電話以及預約資訊。

底下我會簡單介紹一下 Amelia 的架構以及這三個漏洞的細節。

Amelia 基本介紹

安裝好 Amelia 以後,你可以新增一個預約頁面,大概是長這樣:

intro1

在預約時需要提供一些基本資料,例如說姓名以及 email 等等,輸入後即可完成預約:

intro2

完成預約以後,Amelia 會幫你在 WordPress 系統裡面新增一個低權限的帳號,並且把重設密碼的連結寄到剛剛提供的信箱。帳號開通以後,就可以登入 WordPress 管理剛剛的預約:

intro3

使用方式介紹完以後,我們來看一下更技術的部分。

WordPress 外掛與 Amelia 架構介紹

WordPress 的外掛有很多,每一個的寫法都不太一樣,但因為是外掛,所以會呼叫 WordPress 提供的函式來註冊事件。

add_action 這個函式就扮演著很重要的角色,你可以幫特定的 action 加上一個 hook,當這個 action 被觸發時,就會呼叫到你提供的函式。

其中由 wp_ajax_nopriv_ 開頭的 action,可以透過 wp-admin/admin-ajax.php 來呼叫,相關程式碼節錄如下(admin-ajax.php):

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
<?php

$action = $_REQUEST['action'];

if ( is_user_logged_in() ) {
// If no action is registered, return a Bad Request response.
if ( ! has_action( "wp_ajax_{$action}" ) ) {
wp_die( '0', 400 );
}

/**
* Fires authenticated Ajax actions for logged-in users.
*
* The dynamic portion of the hook name, `$action`, refers
* to the name of the Ajax action callback being fired.
*
* @since 2.1.0
*/
do_action( "wp_ajax_{$action}" );
} else {
// If no action is registered, return a Bad Request response.
if ( ! has_action( "wp_ajax_nopriv_{$action}" ) ) {
wp_die( '0', 400 );
}

/**
* Fires non-authenticated Ajax actions for logged-out users.
*
* The dynamic portion of the hook name, `$action`, refers
* to the name of the Ajax action callback being fired.
*
* @since 2.8.0
*/
do_action( "wp_ajax_nopriv_{$action}" );
}

?>

以 Amelia 來說,在 ameliabooking.php 中註冊了兩個 hook:

1
2
3
/** Isolate API calls */
add_action('wp_ajax_wpamelia_api', array('AmeliaBooking\Plugin', 'wpAmeliaApiCall'));
add_action('wp_ajax_nopriv_wpamelia_api', array('AmeliaBooking\Plugin', 'wpAmeliaApiCall'));

nopriv 的代表沒有權限(未登入)也可以呼叫,沒有的代表需要登入 WordPress 系統才能呼叫,而許多的 plugin 會選擇自己處理身份驗證相關的邏輯,所以會把兩個動作都導到同一個地方。

wpAmeliaApiCall 這個函式則是註冊了 routes:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
/**
* API Call
*
* @throws \InvalidArgumentException
*/
public static function wpAmeliaApiCall()
{
try {
/** @var Container $container */
$container = require AMELIA_PATH . '/src/Infrastructure/ContainerConfig/container.php';

$app = new App($container);

// Initialize all API routes
Routes::routes($app);

$app->run();

exit();
} catch (Exception $e) {
echo 'ERROR: ' . $e->getMessage();
}
}

src/Infrastructure/Routes 底下有許多的資料夾跟檔案,裡面負責處理不同的路由,舉例來說,User 相關的路由在 src/Infrastructure/Routes/User/User.php,相關程式碼節錄如下:

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
/**
* Class User
*
* @package AmeliaBooking\Infrastructure\Routes\User
*/
class User
{
/**
* @param App $app
*/
public static function routes(App $app)
{
$app->get('/users/wp-users', GetWPUsersController::class);
$app->post('/users/authenticate', LoginCabinetController::class);
$app->post('/users/logout', LogoutCabinetController::class);

// Customers
$app->get('/users/customers/{id:[0-9]+}', GetCustomerController::class);
$app->get('/users/customers', GetCustomersController::class);
$app->post('/users/customers', AddCustomerController::class);
$app->post('/users/customers/{id:[0-9]+}', UpdateCustomerController::class);
$app->post('/users/customers/delete/{id:[0-9]+}', DeleteUserController::class);
$app->get('/users/customers/effect/{id:[0-9]+}', GetUserDeleteEffectController::class);
$app->post('/users/customers/reauthorize', ReauthorizeController::class);

// Providers
$app->get('/users/providers/{id:[0-9]+}', GetProviderController::class);
$app->get('/users/providers', GetProvidersController::class);
$app->post('/users/providers', AddProviderController::class);
$app->post('/users/providers/{id:[0-9]+}', UpdateProviderController::class);
$app->post('/users/providers/status/{id:[0-9]+}', UpdateProviderStatusController::class);
$app->post('/users/providers/delete/{id:[0-9]+}', DeleteUserController::class);
$app->get('/users/providers/effect/{id:[0-9]+}', GetUserDeleteEffectController::class);

// Current User
$app->get('/users/current', GetCurrentUserController::class);
}
}

那實際上到底要怎麼呼叫到這些路由呢?在 src/Infrastructure/ContainerConfig/request.php 中,針對 request 的 query string 做了一些轉換:

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
<?php

use Slim\Http\Request;
use Slim\Http\Uri;

$entries['request'] = function (AmeliaBooking\Infrastructure\Common\Container $c) {

$curUri = Uri::createFromEnvironment($c->get('environment'));
// 附註:AMELIA_ACTION_SLUG = "action=wpamelia_api&call="
$newRoute = str_replace(
['XDEBUG_SESSION_START=PHPSTORM&' . AMELIA_ACTION_SLUG, AMELIA_ACTION_SLUG],
'',
$curUri->getQuery()
);

$newPath = strpos($newRoute, '&') ? substr(
$newRoute,
0,
strpos($newRoute, '&')
) : $newRoute;

$newQuery = strpos($newRoute, '&') ? substr(
$newRoute,
strpos($newRoute, '&') + 1
) : '';

$request = Request::createFromEnvironment($c->get('environment'))
->withUri(
$curUri
->withPath($newPath)
->withQuery($newQuery)
);

if (method_exists($request, 'getParam') && $request->getParam('showAmeliaErrors')) {
ini_set('display_errors', 1);
ini_set('display_startup_errors', 1);
error_reporting(E_ALL);
}

return $request;
};

簡單來說呢,當你的 request URL 長這樣的時候:/wordpress/wp-admin/admin-ajax.php?action=wpamelia_api&call=/users/wp-users,query string 就是 action=wpamelia_api&call=/users/wp-users,符合 AMELIA_ACTION_SLUG 的地方被換成空白之後,就變成了 /users/wp-users,就對應到了上面的檔案看到的路由,重新交由 Slim 這個 PHP 框架去處理。

/users/wp-users 對應到的是 GetWPUsersController::class,讓我們來看一下 controller 的程式碼:

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
<?php

namespace AmeliaBooking\Application\Controller\User;

use AmeliaBooking\Application\Commands\User\GetWPUsersCommand;
use AmeliaBooking\Application\Controller\Controller;
use Slim\Http\Request;

/**
* Class GetWPUsersController
*
* @package AmeliaBooking\Application\Controller\User
*/
class GetWPUsersController extends Controller
{
/**
* Instantiates the Get WP Users command to hand it over to the Command Handler
*
* @param Request $request
* @param $args
*
* @return GetWPUsersCommand
* @throws \RuntimeException
*/
protected function instantiateCommand(Request $request, $args)
{
$command = new GetWPUsersCommand($args);
$command->setField('id', (int)$request->getQueryParam('id'));
$command->setField('role', $request->getQueryParam('role'));
$requestBody = $request->getParsedBody();
$this->setCommandFields($command, $requestBody);

return $command;
}
}

這邊使用了設計模式中的 Command Pattern,把每一個動作都包裝成一個指令,那這個指令會被誰處理呢?每一個 controller 都繼承了 AmeliaBooking\Application\Controller\Controller,所以處理的程式碼就在裡面:

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
/**
* @param Request $request
* @param Response $response
* @param $args
*
* @return Response
* @throws \InvalidArgumentException
* @throws \RuntimeException
*/
public function __invoke(Request $request, Response $response, $args)
{
/** @var Command $command */
$command = $this->instantiateCommand($request, $args);

if (!wp_verify_nonce($command->getField('ameliaNonce'), 'ajax-nonce') &&
(
$command instanceof DeleteUserCommand ||
$command instanceof DeletePackageCommand ||
$command instanceof DeleteCategoryCommand ||
$command instanceof DeleteServiceCommand ||
$command instanceof DeleteExtraCommand ||
$command instanceof DeleteLocationCommand ||
$command instanceof DeleteEventCommand ||
$command instanceof DeletePaymentCommand ||
$command instanceof DeleteCouponCommand ||
$command instanceof DeleteCustomFieldCommand ||
$command instanceof DeleteAppointmentCommand ||
$command instanceof DeleteBookingCommand ||
$command instanceof DeleteEventBookingCommand ||
$command instanceof DeletePackageCustomerCommand ||
$command instanceof DeleteNotificationCommand
)
) {
return $response->withStatus(self::STATUS_INTERNAL_SERVER_ERROR);
}

/** @var CommandResult $commandResult */
$commandResult = $this->commandBus->handle($command);

if ($commandResult->getUrl() !== null) {
$this->emitSuccessEvent($this->eventBus, $commandResult);

/** @var Response $response */
$response = $response->withHeader('Location', $commandResult->getUrl());
$response = $response->withStatus(self::STATUS_REDIRECT);

return $response;
}

if ($commandResult->hasAttachment() === false) {
$responseBody = [
'message' => $commandResult->getMessage(),
'data' => $commandResult->getData()
];

$this->emitSuccessEvent($this->eventBus, $commandResult);

switch ($commandResult->getResult()) {
case (CommandResult::RESULT_SUCCESS):
$response = $response->withStatus(self::STATUS_OK);

break;
case (CommandResult::RESULT_CONFLICT):
$response = $response->withStatus(self::STATUS_CONFLICT);

break;
default:
$response = $response->withStatus(self::STATUS_INTERNAL_SERVER_ERROR);

break;
}

/** @var Response $response */
$response = $response->withHeader('Content-Type', 'application/json;charset=utf-8');
$response = $response->write(
json_encode(
$commandResult->hasDataInResponse() ?
$responseBody : array_merge($responseBody, ['data' => []])
)
);
}

return $response;
}

這邊先實例化一個指令之後,再丟到 commandBus 去做處理:$this->commandBus->handle($command),程式碼在 src/Infrastructure/ContainerConfig/command.bus.php,節錄部分:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
<?php

defined('ABSPATH') or die('No script kiddies please!');

// @codingStandardsIgnoreStart
$entries['command.bus'] = function ($c) {
$commands = [
// User
User\DeleteUserCommand::class => new User\DeleteUserCommandHandler($c),
User\GetCurrentUserCommand::class => new User\GetCurrentUserCommandHandler($c),
User\GetUserDeleteEffectCommand::class => new User\GetUserDeleteEffectCommandHandler($c),
User\GetWPUsersCommand::class => new User\GetWPUsersCommandHandler($c),

// more commands...
];

return League\Tactician\Setup\QuickStart::create($commands);
};
// @codingStandardsIgnoreEnd

從中可以看出我們的 GetWPUsersCommand 會被 User\GetWPUsersCommandHandler 處理,所以主要的邏輯就在這裡面:

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
class GetWPUsersCommandHandler extends CommandHandler
{
/**
* @param GetWPUsersCommand $command
*
* @return CommandResult
* @throws AccessDeniedException
* @throws InvalidArgumentException
* @throws \AmeliaBooking\Infrastructure\Common\Exceptions\QueryExecutionException
* @throws \Interop\Container\Exception\ContainerException
*/
public function handle(GetWPUsersCommand $command)
{
if (!$this->getContainer()->getPermissionsService()->currentUserCanRead(Entities::EMPLOYEES)) {
throw new AccessDeniedException('You are not allowed to read employees.');
}

if (!$this->getContainer()->getPermissionsService()->currentUserCanRead(Entities::CUSTOMERS)) {
throw new AccessDeniedException('You are not allowed to read customers.');
}

$result = new CommandResult();

$this->checkMandatoryFields($command);

/** @var UserService $userService */
$userService = $this->container->get('users.service');

$adminIds = $userService->getWpUserIdsByRoles(['administrator']);

/** @var WPUserRepository $wpUserRepository */
$wpUserRepository = $this->getContainer()->get('domain.wpUsers.repository');

$result->setResult(CommandResult::RESULT_SUCCESS);
$result->setMessage('Successfully retrieved users.');

$result->setData([
Entities::USER . 's' => $wpUserRepository->getAllNonRelatedWPUsers($command->getFields(), $adminIds)
]);

return $result;
}
}

可以看到業務邏輯都在 handle 這個函式裡面,裡面先檢查了權限,接著透過 userService 抓取相關資料,再來用 $result->setData 設置要回傳的資料,最後回傳結果,交給其他 infra 相關程式碼處理。

另外,在 controller 中可以看到 command 相關的權限檢查:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
if (!wp_verify_nonce($command->getField('ameliaNonce'), 'ajax-nonce') &&
(
$command instanceof DeleteUserCommand ||
$command instanceof DeletePackageCommand ||
$command instanceof DeleteCategoryCommand ||
$command instanceof DeleteServiceCommand ||
$command instanceof DeleteExtraCommand ||
$command instanceof DeleteLocationCommand ||
$command instanceof DeleteEventCommand ||
$command instanceof DeletePaymentCommand ||
$command instanceof DeleteCouponCommand ||
$command instanceof DeleteCustomFieldCommand ||
$command instanceof DeleteAppointmentCommand ||
$command instanceof DeleteBookingCommand ||
$command instanceof DeleteEventBookingCommand ||
$command instanceof DeletePackageCustomerCommand ||
$command instanceof DeleteNotificationCommand
)
) {
return $response->withStatus(self::STATUS_INTERNAL_SERVER_ERROR);
}

如果是這些 delete 的指令,就需要通過 wp_verify_nonce 的檢查,這是什麼東西呢?

wp_verify_nonce 是 WordPress 提供用於安全性檢查的函式,對應的函式是 wp_create_nonce,在 WordPress 後台管理頁面有這樣一行程式碼:var wpAmeliaNonce = '<?php echo wp_create_nonce('ajax-nonce'); ?>';,會產生一個名稱為 ajax-nonce 的 nonce,而這個 nonce 其實就是把一些字串 hash 過後的結果。

如果你拿不到 hash 時用的 salt,基本上不可能偽造出 nonce,因為 salt 預設都非常長,而且都是安裝時隨機產生的:

1
2
3
4
5
6
7
8
define('AUTH_KEY',         ' Xakm<o xQy rw4EMsLKM-?!T+,PFF})[email protected]@< >M%G4Yt>f`z]MON');
define('SECURE_AUTH_KEY', 'LzJ}op]mr|6+![P}Ak:uNdJCJZd>(Hx.-Mh#Tz)pCIU#uGEnfFz|f ;;eU%/U^O~');
define('LOGGED_IN_KEY', '|i|Ux`9<p-h$aFf(qnT:sDO:D1P^wZ$$/[email protected];ddp_<q}6H1)o|a +&JCM');
define('NONCE_KEY', '%:R{[P|,s.KuMltH5}cI;/k<Gx~j!f0I)m_sIyu+&NJZ)-iO>z7X>QYR0Z_XnZ@|');
define('AUTH_SALT', 'eZyT)-Naw]F8CwA*VaW#q*|.)[email protected]}||[email protected]}(dh_r6EbI#A,y|nU2{B#JBW');
define('SECURE_AUTH_SALT', '!=oLUTXh,QW=H `}`L|9/^4-3 STz},T(w}W<I`.JjPi)<Bmf1v,HpGe}T1:Xt7n');
define('LOGGED_IN_SALT', '+XSqHc;@Q*K_b|Z?NC[3H!!EONbh.n<+=uKR:>*c(u`g~EJBf#8u#R{mUEZrozmm');
define('NONCE_SALT', 'h`GXHhD>SLWVfg1(1(N{;.V!MoE(SfbA_ksP@&`[email protected]+rxV{%^VyKT');

因此,透過 wp_verify_nonce,我們可以確保只有已登入的使用者能使用到某些功能,因為沒登入的話拿不到 nonce。

以上就是 Amelia 的基本架構跟處理流程,是我看過的幾個 plugin 中最為漂亮的一個,東西都整理得很好,架構也切得不錯,不會出現一堆雜七雜八的程式碼,要找東西也很好找,只要去 routes 看一下網址跟對應的 controller,循線找到 command 跟 command handler 即可。

接著,就來談談開頭提到的那三個漏洞。

CVE-2022-0720: Amelia < 1.0.47 - Customer+ Arbitrary Appointments Update and Sensitive Data Disclosure

管理訂房相關的模組有兩個,一個叫做 Appointment,另一個叫做 Booking,他們是一對多的關係,一個 Appointment 底下可以對應到多個 Booking,相關路由如下:

src/Infrastructure/Routes/Booking/Appointment/Appointment.php

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class Appointment
{
/**
* @param App $app
*
* @throws \InvalidArgumentException
*/
public static function routes(App $app)
{
$app->get('/appointments', GetAppointmentsController::class);
$app->get('/appointments/{id:[0-9]+}', GetAppointmentController::class);
$app->post('/appointments', AddAppointmentController::class);
$app->post('/appointments/delete/{id:[0-9]+}', DeleteAppointmentController::class);
$app->post('/appointments/{id:[0-9]+}', UpdateAppointmentController::class);
$app->post('/appointments/status/{id:[0-9]+}', UpdateAppointmentStatusController::class);
$app->post('/appointments/time/{id:[0-9]+}', UpdateAppointmentTimeController::class);
}
}

以顯示 appointment 的路由 /appointments/{id:[0-9]+} 為例,對應到 GetAppointmentController,在 controller 中會去呼叫 GetAppointmentCommandHandler,裡面有段程式碼是這樣的:

1
$customerAS->removeBookingsForOtherCustomers($user, new Collection([$appointment]));

在回傳資料前,會把不屬於自己的 booking 全部都過濾掉,所以看不到其他人的資料,有做好權限管理。

而更新 appointment 的路由對應到的 controller 是 UpdateAppointmentController,又對應到了 UpdateAppointmentCommandHandler.php,部分程式碼如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
try {
/** @var AbstractUser $user */
$user = $userAS->authorization(
$command->getPage() === 'cabinet' ? $command->getToken() : null,
$command->getCabinetType()
);
} catch (AuthorizationException $e) {
$result->setResult(CommandResult::RESULT_ERROR);
$result->setData(
[
'reauthorize' => true
]
);

return $result;
}

if ($userAS->isProvider($user) && !$settingsDS->getSetting('roles', 'allowWriteAppointments')) {
throw new AccessDeniedException('You are not allowed to update appointment');
}

// update appointment

開頭有檢查了兩樣東西,第一樣是使用者是否登入,所以儘管沒有 nonce 也可以進來這個路由,在這邊還是會被擋下來。第二樣則是使用者的身份,如果是 provider 才會檢查有沒有權限。

在 Amelia 中基本上有幾個角色,消費者(Customer)、服務提供者(Provider)以及管理員(Admin),所以只要我們不是 provider,就可以通過這邊的檢查。

開頭有提過只要透過 Amelia 的外掛隨便預約一個服務,就可以在 WordPress 的系統中註冊一個 customer 的帳號,這組帳號可以登入 WordPress,來管理自己之前的預約。

因此,這邊的權限檢查是有漏洞的,一個 customer 身份的使用者可以通過這邊的檢查,去竄改其他人的預約。雖然看起來好像很普通,但其實使用者在前台修改自己的預約時,用的是另外一個 /bookings/{id} 的 API,這個 appointment 的 API 我猜預設是給 provider 使用的,所以才沒考慮到 customer 的狀況。

那除了修改 booking 以外,還可以幹嘛呢?我們來看一下更新完的 response:

update booking

我們可以看到 response 中有個 info 欄位,裡面有原本消費者的個人資料,包括姓名以及電話等等,這個欄位是在 src/Application/Services/Reservation/AbstractReservationService.php 中的 processBooking 時儲存的:

1
2
3
4
5
6
7
8
9
10
$appointmentData['bookings'][0]['info'] = json_encode(
[
'firstName' => $appointmentData['bookings'][0]['customer']['firstName'],
'lastName' => $appointmentData['bookings'][0]['customer']['lastName'],
'phone' => $appointmentData['bookings'][0]['customer']['phone'],
'locale' => $appointmentData['locale'],
'timeZone' => $appointmentData['timeZone'],
'urlParams' => !empty($appointmentData['urlParams']) ? $appointmentData['urlParams'] : null,
]
);

總結一下,因為權限檢查沒做好,所以 customer 可以更新其他人的預約,並且看到消費者的個人資料,而 appointment 的 ID 是流水號,所以直接列舉一下,就可以把系統中所有人的個資都撈出來。

修復方式

在 1.0.47 版中,有做出了兩個變動,第一個是針對我回報的問題,加上了對於 customer 的權限檢查:

1
2
3
if ($userAS->isCustomer($user)) {
throw new AccessDeniedException('You are not allowed to update appointment');
}

第二個改動則是 routes 的權限檢查,從負面表列變成正面表列,只有幾個特定的 command 不需登入:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
public function validateNonce($request)
{
if ($request->getMethod() === 'POST' &&
!self::getToken() &&
!($this instanceof LoginCabinetCommand) &&
!($this instanceof AddBookingCommand) &&
!($this instanceof AddStatsCommand) &&
!($this instanceof MolliePaymentCommand) &&
!($this instanceof MolliePaymentNotifyCommand) &&
!($this instanceof PayPalPaymentCommand) &&
!($this instanceof PayPalPaymentCallbackCommand) &&
!($this instanceof RazorpayPaymentCommand) &&
!($this instanceof WooCommercePaymentCommand) &&
!($this instanceof SuccessfulBookingCommand)
) {
return wp_verify_nonce($request->getQueryParams()['ameliaNonce'], 'ajax-nonce');
}
return true;
}

CVE-2022-0825: Amelia < 1.0.49 - Customer+ Arbitrary Appointments Status Update

這個漏洞跟上一個類似,都是屬於權限管理的問題,而這個漏洞的路由是 $app->post('/appointments/status/{id:[0-9]+}', UpdateAppointmentStatusController::class);,對應到的程式碼在 src/Application/Commands/Booking/Appointment/UpdateAppointmentStatusCommandHandler.php,開頭有先做權限檢查:

1
2
3
4
5
if (!$this->getContainer()->getPermissionsService()->currentUserCanWriteStatus(Entities::APPOINTMENTS)) {
throw new AccessDeniedException('You are not allowed to update appointment status');
}

// update appointment

我們繼續往下追,去看看 currentUserCanWriteStatus 是怎麼實作的:

1
2
3
4
public function currentUserCanWriteStatus($object)
{
return $this->userCan($this->currentUser, $object, self::WRITE_STATUS_PERMISSIONS);
}

再往下追,找到 userCan

1
2
3
4
5
6
7
public function userCan($user, $object, $permission)
{
if ($user instanceof Admin) {
return true;
}
return $this->permissionsChecker->checkPermissions($user, $object, $permission);
}

再往下一層,在 src/Infrastructure/WP/PermissionsService/PermissionsChecker.php 中可以看到 checkPermissions 的實作:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
public function checkPermissions($user, $object, $permission)
{
// Admin can do all
if ($user instanceof Admin) {
return true;
}

// Get the WP role name of the user, rollback to customer by default
$wpRoleName = $user !== null ? 'wpamelia-' . $user->getType() : 'wpamelia-customer';
// Get the wp name of capability we are looking for.
$wpCapability = "amelia_{$permission}_{$object}";

if ($user !== null && $user->getExternalId() !== null) {
return user_can($user->getExternalId()->getValue(), $wpCapability);
}

// If user is guest check does it have capability
$wpRole = get_role($wpRoleName);
return $wpRole !== null && isset($wpRole->capabilities[$wpCapability]) ?
(bool)$wpRole->capabilities[$wpCapability] : false;
}

這邊有個值得注意的地方,就是如果 user 是 null 的話,會被當成 customer 來看待,而實際檢查有沒有權限要看 capabilities 這個 table,在 src/Infrastructure/WP/config/Roles.php

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// Customer
[
'name' => 'wpamelia-customer',
'label' => __('Amelia Customer', 'amelia'),
'capabilities' => [
'read' => true,
'amelia_read_menu' => true,
'amelia_read_calendar' => true,
'amelia_read_appointments' => true,
'amelia_read_events' => true,
'amelia_write_status_appointments' => true,
'amelia_write_time_appointments' => true,
]
],

其中 amelia_write_status_appointments 是 true,代表 customer 有權限更新狀態。

剩下的部分就跟上一個漏洞一樣了,更新 appointment 之後資料會整包回傳,透過 info 這個欄位可以看到消費者的個人資料。另外,這個漏洞在 1.0.47 以前會是 pre-auth 的,因為 1.0.47 以前 routes 的權限檢查還沒變成正面表列,所以沒登入也可以存取到這個指令,再加上 user 是 null 的話預設是消費者身份,完成了整條攻擊鏈的串接:

update booking status

修復方式

在 1.0.49 版中,移除了 customer 的 amelia_write_status_appointments 這個權限。

CVE-2022-0837: Amelia < 1.0.48 - Customer+ SMS Service Abuse and Sensitive Data Disclosure

來看最後一個權限檢查相關漏洞,出問題的路由是 $app->post('/notifications/sms', SendAmeliaSmsApiRequestController::class);,對應到的是 SendAmeliaSmsApiRequestCommandHandler

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public function handle(SendAmeliaSmsApiRequestCommand $command)
{
$result = new CommandResult();

/** @var SMSAPIServiceInterface $smsApiService */
$smsApiService = $this->getContainer()->get('application.smsApi.service');

// Call method dynamically and pass data to the function. Method name is the request field.
$apiResponse = $smsApiService->{$command->getField('action')}($command->getField('data'));

$result->setResult(CommandResult::RESULT_SUCCESS);
$result->setMessage('Amelia SMS API request successful');
$result->setData($apiResponse);

return $result;
}

可以看到這邊沒有做任何的權限檢查,而我們可以控制傳到這邊的參數:

1
$apiResponse = $smsApiService->{$command->getField('action')}($command->getField('data'));

在 smsApiService 中有不少方法,而其中只有一個參數的包括可以拿到管理員個人資訊的 getUserInfo,可以拿到付款紀錄的 getPaymentHistory,以及可以發送測試簡訊的 testNotification

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
public function getUserInfo()
{
$route = 'auth/info';

return $this->sendRequest($route, true);
}

public function getPaymentHistory($data)
{
$route = '/payment/history';

return $this->sendRequest($route, true, $data);
}

public function testNotification($data)
{
$route = '/sms/send';

/** @var SettingsService $settingsService */
$settingsService = $this->container->get('domain.settings.service');

/** @var EmailNotificationService $notificationService */
$notificationService = $this->container->get('application.emailNotification.service');

/** @var PlaceholderService $placeholderService */
$placeholderService = $this->container->get("application.placeholder.{$data['type']}.service");

$appointmentsSettings = $settingsService->getCategorySettings('appointments');

$notification = $notificationService->getById($data['notificationTemplate']);

$dummyData = $placeholderService->getPlaceholdersDummyData('sms');

$isForCustomer = $notification->getSendTo()->getValue() === NotificationSendTo::CUSTOMER;

$placeholderStringRec = 'recurring' . 'Placeholders' . ($isForCustomer ? 'Customer' : '') . 'Sms';
$placeholderStringPack = 'package' . 'Placeholders' . ($isForCustomer ? 'Customer' : '') . 'Sms';

$dummyData['recurring_appointments_details'] = $placeholderService->applyPlaceholders($appointmentsSettings[$placeholderStringRec], $dummyData);
$dummyData['package_appointments_details'] = $placeholderService->applyPlaceholders($appointmentsSettings[$placeholderStringPack], $dummyData);


$body = $placeholderService->applyPlaceholders(
$notification->getContent()->getValue(),
$dummyData
);

$data = [
'to' => $data['recipientPhone'],
'from' => $settingsService->getSetting('notifications', 'smsAlphaSenderId'),
'body' => $body
];

return $this->sendRequest($route, true, $data);
}

實際測試截圖:

sms1

發送測試簡訊:

sms2

發送測試簡訊也是要扣錢的,我們只要一直打這個 endpoint,就會一直發送測試簡訊然後一直扣款,可以利用這個漏洞把管理員的錢燒光。

修復方式

在 1.0.48 版中,於 controller 內加上了權限檢查:

1
2
3
if (!$this->getContainer()->getPermissionsService()->currentUserCanWrite(Entities::NOTIFICATIONS)) {
throw new AccessDeniedException('You are not allowed to send test email');
}

總結

當開發的軟體變得愈來愈複雜,開發者往往容易忽略一些基本的權限檢查,以及對於權限有著錯誤的假設。舉例來說,雖然 appointment 相關的 API 是給 provider 用的,前端的消費者看不到這些 API,但是 WordPress 外掛的程式碼都是開放的,任何人只要看了程式碼,都能找出所有的 API 路徑。

在實作各種功能時,要記得把權限檢查放在第一位,確認當前的使用者對於欲操作的資源有權限以後,才繼續後面的流程。

最後附上時間軸:

2022-02-20 透過 WPScan 回報更新預約漏洞,保留 CVE-2022-0720
2022-03-01 發布 1.0.47 版,修復 CVE-2022-0720,部分資訊公開於 WPScan
2022-03-02 透過 WPScan 回報更新預約狀態漏洞,保留 CVE-2022-0825
2022-03-03 透過 WPScan 回報 SMS 相關漏洞,保留 CVE-2022-0837
2022-03-09 發布 1.0.48 版,修復 CVE-2022-0837,部分資訊公開於 WPScan
2022-03-14 發布 1.0.49 版,修復 CVE-2022-0825,部分資訊公開於 WPScan
2022-03-26 漏洞細節公開於 WPScan
2022-03-30 文章發佈