Sensitive Data Disclosure in WordPress Plugin Amelia < 1.0.49
2022-3-30 00:0:0 Author: blog.huli.tw(查看原文) 阅读量:39 收藏

Amelia is a WordPress plugin for booking systems developed by TNS. With 40,000+ active installations, it has been used for the clinic, hair salon, tutor, and so on.

In March, we studied the source code of Amelia and found three vulnerabilities in the end:

  • 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)

By exploiting these vulnerabilities, a malicious actor could get all the customer’s data, including name, phone, and booking details.

In this article, I will talk about the code structure of Amelia and the details of three vulnerabilities.

A Brief Introduction to Amelia

After installing Amelia plugin, the admin can create a new booking page:

intro1

As a customer, basic personal information like name and email should be provided before making a booking:

intro2

After the customer finished booking, Amelia will create a new low-privilege account for it and send a reset password email to enable the account.

Then, the customer can log into WordPress and manage their bookings:

intro3

How WordPress Plugin Works and the Code Structure of Amelia

When developing a WordPress plugin, the developer uses add_action to add a hook to the WordPress system. The hook function will be invoked when the corresponding action has been called.

The action starts with wp_ajax_nopriv_ can be invoked via wp-admin/admin-ajax.php, following is the excerpt of 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 ( ! has_action( "wp_ajax_{$action}" ) ) {
wp_die( '0', 400 );
}









do_action( "wp_ajax_{$action}" );
} else {

if ( ! has_action( "wp_ajax_nopriv_{$action}" ) ) {
wp_die( '0', 400 );
}









do_action( "wp_ajax_nopriv_{$action}" );
}

?>

Amelia registered two hooks in ameliabooking.php:

1
2
3

add_action('wp_ajax_wpamelia_api', array('AmeliaBooking\Plugin', 'wpAmeliaApiCall'));
add_action('wp_ajax_nopriv_wpamelia_api', array('AmeliaBooking\Plugin', 'wpAmeliaApiCall'));

The difference between wp_ajax_wpamelia_api and wp_ajax_nopriv_wpamelia_api is that the former requires authenticated user to perform the action, while the latter requires none.

As you can see, many plugins choose to handle both actions in the same place, to deal with the permission check itself.

In wpAmeliaApiCall, a few routes have been registered:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23





public static function wpAmeliaApiCall()
{
try {

$container = require AMELIA_PATH . '/src/Infrastructure/ContainerConfig/container.php';

$app = new App($container);


Routes::routes($app);

$app->run();

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

There are a few files in src/Infrastructure/Routes folder for handling routing. For example, src/Infrastructure/Routes/User/User.php is responsible for the routing of /users:

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
{



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


$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);


$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);


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

Usually, the routing is related to the URL path directly, but Amelia is a bit different because it’s a WordPress plugin.

In Amelia, it’s based on the query string, instead of the path of URL(src/Infrastructure/ContainerConfig/request.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
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'));

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

For example, when the request URL is /wordpress/wp-admin/admin-ajax.php?action=wpamelia_api&call=/users/wp-users, query string is action=wpamelia_api&call=/users/wp-users. After AMELIA_ACTION_SLUG has been replaced with empty string, the remaining part is /users/wp-users, that’s how the system handles routes.

Let’s check GetWPUsersController::class, the corresponding controller for /users/wp-users:

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 extends Controller
{









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

We can see a classic design pattern here: Command Pattern, every action is a command. The command will be processed by its parent class 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









public function __invoke(Request $request, Response $response, $args)
{

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


$commandResult = $this->commandBus->handle($command);

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


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


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

return $response;
}

After instantiated the command, it called $this->commandBus->handle($command). Following is the excerpt of 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!');


$entries['command.bus'] = function ($c) {
$commands = [

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),


];

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

We can see that the GetWPUsersCommand is taken by User\GetWPUsersCommandHandler, so the main logic is inside this file:

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
{









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


$userService = $this->container->get('users.service');

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


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

Besides, you can also see the part of permission check:

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

If the command is about deleting, need to pass the check of wp_verify_nonce, what is it?

wp_verify_nonce is a function provided by WordPress to perform security check. This nonce is generated in admin page via var wpAmeliaNonce = '<?php echo wp_create_nonce('ajax-nonce'); ?>';, and the nonce is actually the result of hashing.

In theory, it’s not possible to spoof the nonce unless you can get the salt, which is generated randomly at install time:

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>[email protected]|');
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([email protected]&`[email protected]+rxV{%^VyKT');

So, we can ensure that only authenticated users have the ability to use certain features via wp_verify_nonce.

Phew! That’s all about the structure of Amelia. It’s elegant compared to other plugins, and it’s easy to find what you want.

Finally, it’s time to talk about the vulnerabilities.

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

There are two modules for managing the booking. One is “Appointment”, the other is “Booking”, it’s a one-to-many relationship. One appointment can have multiple bookings.

Below are the routes for the appointment module:

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
{





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

Take /appointments/{id:[0-9]+} as an example, you can’t see other customer’s booking because there is a line to remove it in GetAppointmentCommandHandler:

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

And here is the file for updating appointment(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 {

$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');
}


In the beginning, there are two checks. The first one is to check if the user is logged in, and the second one is to check if the user’s role is “Provider”.

There are a few roles in Amelia: Customer, (Service) Provider, and Admin. So, as a customer, we can pass the check and update other customer’s appointments!

When the customer manages their bookings, they use another module called “booking” because the appointment module is for providers. I guess that’s why there is no permission check for the customer role because the developer has a false assumption that the customer won’t access this endpoint at all.

What can we do besides updating the booking? Let’s see the response:

update booking

There is a field called “info”, it contains the personal data of the customer who booked it. This field is added by processBooking in src/Application/Services/Reservation/AbstractReservationService.php :

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,
]
);

To sum up, a customer can update other customers’ appointments and see their personal information due to a flawed permission check. Moreover, the appointment ID is a serial number so it’s easy to enumerate.

Remediation

In 1.0.47, they made two changes.

The first one is to implement the permission check for customer:

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

The other is about the permission check for routes, from the positive list to the negative list, only a few commands are accessible without logging in:

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

This vulnerability is quite similar to the previous one, it’s also about permission check.

The route for updating appointment status is $app->post('/appointments/status/{id:[0-9]+}', UpdateAppointmentStatusController::class);, and the main handler is src/Application/Commands/Booking/Appointment/UpdateAppointmentStatusCommandHandler.php.

There is a permission check in the very beginning:

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


Let’s dive in and see what is the implementation of currentUserCanWriteStatus:

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

Keep diving, check 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);
}

In src/Infrastructure/WP/PermissionsService/PermissionsChecker.php, we can find the implementation of 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)
{

if ($user instanceof Admin) {
return true;
}


$wpRoleName = $user !== null ? 'wpamelia-' . $user->getType() : 'wpamelia-customer';

$wpCapability = "amelia_{$permission}_{$object}";

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


$wpRole = get_role($wpRoleName);
return $wpRole !== null && isset($wpRole->capabilities[$wpCapability]) ?
(bool)$wpRole->capabilities[$wpCapability] : false;
}

It was noteworthy that if the user is null, it will be treated as a customer. To check if a role has certain permission, we need to see the capabilities table in src/Infrastructure/WP/config/Roles.php:

1
2
3
4
5
6
7
8
9
10
11
12
13
14

[
'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 is true, so the customer has permission to update the appointment status.

The rest part is just like the last vulnerability, the response of updating the appointment has a field called info, which contains the personal information of the customer who booked it.

By the way, the vulnerability is pre-auth before 1.0.48, because in 1.0.47 the permission check for routes is incomplete, an unauthenticated user can access this endpoint as well.

update booking status

Remediation

Customer role has no permission of amelia_write_status_appointments since 1.0.49.

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

Let’s see the last vulnerability, it’s still about permission check.

The route is $app->post('/notifications/sms', SendAmeliaSmsApiRequestController::class);, and the handler is 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();


$smsApiService = $this->getContainer()->get('application.smsApi.service');


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

As you see, there is no permission check at all, and we can control the parameters here:

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

There are few methods with only one parameter, including getUserInfo, getPaymentHistory and 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';


$settingsService = $this->container->get('domain.settings.service');


$notificationService = $this->container->get('application.emailNotification.service');


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

Screenshot:

sms1

Send test notification:

sms2

Sending test notifications still costs real money, so we can drain out the account by keep calling this API.

Remediation

In 1.0.48, they added permission check in the controller:

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

Conclusion

When the software becomes more and more complex, developers usually overlook some basic permission checks and have false assumptions sometimes.

For example, although front-end customers can’t see appointments-related API because it’s for providers, we can still find those API endpoints by looking at the source code in WordPress SVN.

Developers should be cautions with those authorizations when implementing those features, to make sure the current user has permission to do certain operations.

Disclosure timeline

2022-02-20 Report updating appointment vulnerability via WPScan, reserved CVE-2022-0720
2022-03-01 1.0.47 is published, fixed CVE-2022-0720. WPScan
2022-03-02 Report updating appointment vulnerability via WPScan, reserved CVE-2022-0825
2022-03-03 Report SMS related vulnerability via WPScan, reserved CVE-2022-0837
2022-03-09 1.0.48 is published, fixed CVE-2022-0837. WPScan
2022-03-14 1.0.49 is published, fixed CVE-2022-0825. WPScan
2022-03-26 Details and POC have been disclosed on WPScan
2022-03-30 Blog post published


文章来源: https://blog.huli.tw/2022/03/30/en/amelia-wordpress-plugin-sensitive-data-exposure-detail/
如有侵权请联系:admin#unsafe.sh