WordPress Plugin Amelia < 1.0.49 敏感資訊洩露漏洞細節

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

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

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

底下我會簡單介紹一下 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):

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

/** 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:

/**
* 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,相關程式碼節錄如下:

/**
* 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 做了一些轉換:

<?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 的程式碼:

<?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,所以處理的程式碼就在裡面:

/**
* @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,節錄部分:

<?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 處理,所以主要的邏輯就在這裡面:

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 相關的權限檢查:

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 預設都非常長,而且都是安裝時隨機產生的:

define('AUTH_KEY',         ' Xakm<o xQy rw4EMsLKM-?!T+,PFF})H4lzcW57AF0U@N@< >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$$/Ra@miTJi9G;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*|.)g@o}||wf~@C-YSt}(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@&`+AycHcAV$+?@3q+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

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,裡面有段程式碼是這樣的:

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

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

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

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 時儲存的:

$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 的權限檢查:

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

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

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,開頭有先做權限檢查:

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

// update appointment

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

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

再往下追,找到 userCan

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 的實作:

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

// 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

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;
}

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

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

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

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 內加上了權限檢查:

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 文章發佈

Tag

Recommendation

  1. 有缺陷的重設密碼機制如何演變成帳號奪取漏洞?以 Matters 為例
  2. 基於 JS 原型鏈的攻擊手法:Prototype Pollution
  3. 關於 email security 的大小事 — 設定篇 SPF
  4. 從編譯器優化角度初探 Javascript的V8 引擎
  5. 從Indexing的角度切入MySQL-Innodb與PostgreSQL的效能比較

Discussion(login required)