Published : August 15, 2026

CVE-2026-17581: WCPOS <= 1.9.14 Authenticated (Shop Manager+) Code Injection via 'thermal' Template Engine PoC, Patch Analysis & Rule

Severity High (CVSS 7.2)
CWE 94
Vulnerable Version 1.9.14
Patched Version 1.9.15
Disclosed August 14, 2026

Analysis Overview

Atomic Edge analysis of CVE-2026-17581: This vulnerability allows an authenticated attacker with Shop Manager-level access or above to achieve arbitrary code execution on the server hosting a WordPress instance with the WCPOS – Point of Sale plugin installed. The flaw resides in the receipt template rendering system, specifically in the mismanagement of the ‘thermal’ template engine. The vulnerability is rated as High severity with a CVSS score of 7.2.

The root cause is a dispatch error in the ‘Receipt_Renderer_Factory::create()’ method, located in `woocommerce-pos/includes/Services/Receipt_Renderer_Factory.php`. Within this factory, a switch statement selects the appropriate renderer class based on the template’s engine type. In versions up to and including 1.9.14, the case for the ‘thermal’ engine was missing from the switch. Consequently, when a template specified the ‘thermal’ engine, the switch’s default case was triggered, instantiating the `Legacy_Php_Renderer`. Unlike thermal-specific renderers, `Legacy_Php_Renderer` processes template content by writing it to a temporary file and executing it via PHP’s `include()` function. This design is intended for legacy PHP templates, but it treats the raw content of a thermal template as executable PHP code.

To exploit this, an authenticated attacker must have Shop Manager-level permissions. They would navigate to the WCPOS receipt template editing interface (e.g., the ‘Templates’ or ‘Settings’ section within WCPOS). The attacker would create or edit a receipt template, set its engine to ‘thermal’, and inject a malicious PHP payload, such as “, into the template’s content body. The save operation enforces a nonce check and capability validation, but at this privilege level the attacker possesses the required nonce and capability. Once saved, rendering any receipt with this template would cause the plugin to invoke the vulnerable `Legacy_Php_Renderer::render()` method, which writes the malicious template content to a temporary file and executes it, resulting in the execution of the injected PHP code.

The patch in version 1.9.15 introduces a dedicated `Thermal_Html_Renderer` class and registers it in the factory’s switch statement. The new case for ‘thermal’ returns an instance of this renderer, preventing the fallback to the `Legacy_Php_Renderer`. The `Thermal_Html_Renderer` parses the template content through a safe, thermal-specific pipeline which builds a strict XML AST, escapes any receipt data, and discards all non-thermal markup, including PHP processing instructions. If the parser encounters malformed content (like raw PHP), it throws an exception, and the renderer catches it, logs the error, and fails closed by outputting a harmless HTML comment instead of executing the template content.

Successful exploitation of this vulnerability grants the attacker complete remote code execution on the server, operating with the privileges of the web server user. This leads to full site compromise, enabling the attacker to steal database credentials, modify or delete arbitrary files, install backdoors, and pivot to other resources on the network. Given the administrative level of access that RCE provides, the confidentiality, integrity, and availability of the site and its underlying infrastructure are all severely compromised.

Differential between vulnerable and patched code

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

Code Diff
--- a/woocommerce-pos/includes/Services/Receipt_Renderer_Factory.php
+++ b/woocommerce-pos/includes/Services/Receipt_Renderer_Factory.php
@@ -10,6 +10,7 @@
 use WCPOSWooCommercePOSInterfacesReceipt_Renderer_Interface;
 use WCPOSWooCommercePOSTemplatesRenderersLegacy_Php_Renderer;
 use WCPOSWooCommercePOSTemplatesRenderersLogicless_Renderer;
+use WCPOSWooCommercePOSTemplatesRenderersThermal_Html_Renderer;

 /**
  * Receipt_Renderer_Factory class.
@@ -26,6 +27,11 @@
 		switch ( $engine ) {
 			case 'logicless':
 				return new Logicless_Renderer();
+			case 'thermal':
+				// Thermal content is stored raw (kses-exempt), so it must render
+				// through the thermal pipeline — never the PHP-executing legacy
+				// renderer, which would treat the content as executable PHP.
+				return new Thermal_Html_Renderer();
 			case 'legacy-php':
 			default:
 				return new Legacy_Php_Renderer();
--- a/woocommerce-pos/includes/Templates/Renderers/Thermal_Html_Renderer.php
+++ b/woocommerce-pos/includes/Templates/Renderers/Thermal_Html_Renderer.php
@@ -0,0 +1,110 @@
+<?php
+/**
+ * Thermal HTML receipt renderer.
+ *
+ * Renders a thermal (receipt-printer) template to HTML for the browser print
+ * surface. Thermal template content is stored raw — it is exempt from wp_kses
+ * because it is XML markup for printers, not HTML — so it must NEVER be executed
+ * as PHP. This renderer runs the content through the thermal pipeline
+ * (Mustache render -> XML AST -> HTML), which escapes receipt data and discards
+ * anything that is not recognised thermal markup (PHP processing instructions
+ * included). The include-based Legacy_Php_Renderer must never receive thermal
+ * content.
+ *
+ * @package WCPOSWooCommercePOSTemplatesRenderers
+ */
+
+namespace WCPOSWooCommercePOSTemplatesRenderers;
+
+use WCPOSWooCommercePOSInterfacesReceipt_Renderer_Interface;
+use WCPOSWooCommercePOSLogger;
+use WCPOSWooCommercePOSTemplatesThermalHtml_Thermal_Emitter;
+use WCPOSWooCommercePOSTemplatesThermalThermal_Renderer;
+use WC_Abstract_Order;
+
+/**
+ * Thermal_Html_Renderer class.
+ */
+class Thermal_Html_Renderer implements Receipt_Renderer_Interface {
+	/**
+	 * Render a thermal template to HTML.
+	 *
+	 * @param array                  $template     Template metadata/content.
+	 * @param WC_Abstract_Order|null $order        Order object, or null for sample-data preview.
+	 * @param array                  $receipt_data Canonical receipt payload (unused; the thermal pipeline rebuilds its own data).
+	 */
+	public function render( array $template, ?WC_Abstract_Order $order, array $receipt_data ): void {
+		// The thermal pipeline builds its receipt data from a concrete order.
+		if ( ! $order instanceof WC_Abstract_Order ) {
+			echo '<!-- Thermal receipt preview requires an order -->';
+			return;
+		}
+
+		$paper_width_px = $this->paper_width_px( $template );
+
+		try {
+			$ast  = ( new Thermal_Renderer() )->build_ast( $template, $order );
+			$html = ( new Html_Thermal_Emitter() )->emit(
+				$ast,
+				array( 'paper_width_px' => $paper_width_px )
+			);
+		} catch ( Throwable $e ) {
+			// Malformed thermal markup (e.g. a raw PHP payload with no <receipt>
+			// root) throws during parsing. Fail closed with a harmless comment,
+			// but log the cause so a genuinely broken template is diagnosable.
+			Logger::log(
+				sprintf(
+					'Thermal receipt render failed for template %s: %s',
+					isset( $template['id'] ) ? (string) $template['id'] : 'unknown',
+					$e->getMessage()
+				)
+			);
+			echo '<!-- Thermal receipt could not be rendered -->';
+			return;
+		}
+
+		// The emitter leaves its output width-agnostic (the PDF path constrains it
+		// via the physical page size). On the browser-print surface the page can be
+		// wider than the roll, so constrain to the resolved paper width here or the
+		// row tables would stretch across the whole page.
+		$open = '<div style="width:' . esc_attr( $this->format_px( $paper_width_px ) )
+			. 'px;max-width:100%;margin:0 auto;">';
+
+		// $html is built by the thermal emitter, which escapes receipt data; $open
+		// is a fixed wrapper with an escaped numeric width.
+		echo $open . $html . '</div>'; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
+	}
+
+	/**
+	 * Format a pixel value for inline CSS without a trailing decimal point.
+	 *
+	 * @param float $px Pixel value.
+	 *
+	 * @return string
+	 */
+	private function format_px( float $px ): string {
+		return rtrim( rtrim( number_format( $px, 2, '.', '' ), '0' ), '.' );
+	}
+
+	/**
+	 * Resolve the template's paper width in CSS pixels (96dpi).
+	 *
+	 * Template metadata declares the physical roll ('58mm' / '80mm'); fall back
+	 * to 80mm when absent or out of range so the emitter can scale the character
+	 * grid to fit the page.
+	 *
+	 * @param array $template Template metadata/content.
+	 *
+	 * @return float Paper width in CSS px.
+	 */
+	private function paper_width_px( array $template ): float {
+		$raw = isset( $template['paper_width'] ) ? (string) $template['paper_width'] : '';
+		$mm  = (float) $raw; // Leading-number cast: '58mm' -> 58.0.
+
+		if ( $mm < 25.0 || $mm > 250.0 ) {
+			$mm = 80.0;
+		}
+
+		return round( $mm * 96 / 25.4, 2 );
+	}
+}
--- a/woocommerce-pos/vendor/autoload.php
+++ b/woocommerce-pos/vendor/autoload.php
@@ -19,4 +19,4 @@

 require_once __DIR__ . '/composer/autoload_real.php';

-return ComposerAutoloaderInitd314d8e08ecddaafa88a270bcf997c4c::getLoader();
+return ComposerAutoloaderInit8454f2701641055150b8e4897e686c14::getLoader();
--- a/woocommerce-pos/vendor/composer/autoload_classmap.php
+++ b/woocommerce-pos/vendor/composer/autoload_classmap.php
@@ -372,6 +372,7 @@
     'WCPOS\WooCommercePOS\Templates\Received' => $baseDir . '/includes/Templates/Received.php',
     'WCPOS\WooCommercePOS\Templates\Renderers\Legacy_Php_Renderer' => $baseDir . '/includes/Templates/Renderers/Legacy_Php_Renderer.php',
     'WCPOS\WooCommercePOS\Templates\Renderers\Logicless_Renderer' => $baseDir . '/includes/Templates/Renderers/Logicless_Renderer.php',
+    'WCPOS\WooCommercePOS\Templates\Renderers\Thermal_Html_Renderer' => $baseDir . '/includes/Templates/Renderers/Thermal_Html_Renderer.php',
     'WCPOS\WooCommercePOS\Templates\Thermal\Epos_Xml_Thermal_Emitter' => $baseDir . '/includes/Templates/Thermal/Epos_Xml_Thermal_Emitter.php',
     'WCPOS\WooCommercePOS\Templates\Thermal\Escpos_Thermal_Emitter' => $baseDir . '/includes/Templates/Thermal/Escpos_Thermal_Emitter.php',
     'WCPOS\WooCommercePOS\Templates\Thermal\Html_Thermal_Emitter' => $baseDir . '/includes/Templates/Thermal/Html_Thermal_Emitter.php',
--- a/woocommerce-pos/vendor/composer/autoload_real.php
+++ b/woocommerce-pos/vendor/composer/autoload_real.php
@@ -2,7 +2,7 @@

 // autoload_real.php @generated by Composer

-class ComposerAutoloaderInitd314d8e08ecddaafa88a270bcf997c4c
+class ComposerAutoloaderInit8454f2701641055150b8e4897e686c14
 {
     private static $loader;

@@ -22,16 +22,16 @@
             return self::$loader;
         }

-        spl_autoload_register(array('ComposerAutoloaderInitd314d8e08ecddaafa88a270bcf997c4c', 'loadClassLoader'), true, true);
+        spl_autoload_register(array('ComposerAutoloaderInit8454f2701641055150b8e4897e686c14', 'loadClassLoader'), true, true);
         self::$loader = $loader = new ComposerAutoloadClassLoader(dirname(__DIR__));
-        spl_autoload_unregister(array('ComposerAutoloaderInitd314d8e08ecddaafa88a270bcf997c4c', 'loadClassLoader'));
+        spl_autoload_unregister(array('ComposerAutoloaderInit8454f2701641055150b8e4897e686c14', 'loadClassLoader'));

         require __DIR__ . '/autoload_static.php';
-        call_user_func(ComposerAutoloadComposerStaticInitd314d8e08ecddaafa88a270bcf997c4c::getInitializer($loader));
+        call_user_func(ComposerAutoloadComposerStaticInit8454f2701641055150b8e4897e686c14::getInitializer($loader));

         $loader->register(true);

-        $filesToLoad = ComposerAutoloadComposerStaticInitd314d8e08ecddaafa88a270bcf997c4c::$files;
+        $filesToLoad = ComposerAutoloadComposerStaticInit8454f2701641055150b8e4897e686c14::$files;
         $requireFile = Closure::bind(static function ($fileIdentifier, $file) {
             if (empty($GLOBALS['__composer_autoload_files'][$fileIdentifier])) {
                 $GLOBALS['__composer_autoload_files'][$fileIdentifier] = true;
--- a/woocommerce-pos/vendor/composer/autoload_static.php
+++ b/woocommerce-pos/vendor/composer/autoload_static.php
@@ -4,7 +4,7 @@

 namespace ComposerAutoload;

-class ComposerStaticInitd314d8e08ecddaafa88a270bcf997c4c
+class ComposerStaticInit8454f2701641055150b8e4897e686c14
 {
     public static $files = array (
         '23c18046f52bef3eea034657bafda50f' => __DIR__ . '/..' . '/symfony/polyfill-php81/bootstrap.php',
@@ -451,6 +451,7 @@
         'WCPOS\WooCommercePOS\Templates\Received' => __DIR__ . '/../..' . '/includes/Templates/Received.php',
         'WCPOS\WooCommercePOS\Templates\Renderers\Legacy_Php_Renderer' => __DIR__ . '/../..' . '/includes/Templates/Renderers/Legacy_Php_Renderer.php',
         'WCPOS\WooCommercePOS\Templates\Renderers\Logicless_Renderer' => __DIR__ . '/../..' . '/includes/Templates/Renderers/Logicless_Renderer.php',
+        'WCPOS\WooCommercePOS\Templates\Renderers\Thermal_Html_Renderer' => __DIR__ . '/../..' . '/includes/Templates/Renderers/Thermal_Html_Renderer.php',
         'WCPOS\WooCommercePOS\Templates\Thermal\Epos_Xml_Thermal_Emitter' => __DIR__ . '/../..' . '/includes/Templates/Thermal/Epos_Xml_Thermal_Emitter.php',
         'WCPOS\WooCommercePOS\Templates\Thermal\Escpos_Thermal_Emitter' => __DIR__ . '/../..' . '/includes/Templates/Thermal/Escpos_Thermal_Emitter.php',
         'WCPOS\WooCommercePOS\Templates\Thermal\Html_Thermal_Emitter' => __DIR__ . '/../..' . '/includes/Templates/Thermal/Html_Thermal_Emitter.php',
@@ -466,10 +467,10 @@
     public static function getInitializer(ClassLoader $loader)
     {
         return Closure::bind(function () use ($loader) {
-            $loader->prefixLengthsPsr4 = ComposerStaticInitd314d8e08ecddaafa88a270bcf997c4c::$prefixLengthsPsr4;
-            $loader->prefixDirsPsr4 = ComposerStaticInitd314d8e08ecddaafa88a270bcf997c4c::$prefixDirsPsr4;
-            $loader->prefixesPsr0 = ComposerStaticInitd314d8e08ecddaafa88a270bcf997c4c::$prefixesPsr0;
-            $loader->classMap = ComposerStaticInitd314d8e08ecddaafa88a270bcf997c4c::$classMap;
+            $loader->prefixLengthsPsr4 = ComposerStaticInit8454f2701641055150b8e4897e686c14::$prefixLengthsPsr4;
+            $loader->prefixDirsPsr4 = ComposerStaticInit8454f2701641055150b8e4897e686c14::$prefixDirsPsr4;
+            $loader->prefixesPsr0 = ComposerStaticInit8454f2701641055150b8e4897e686c14::$prefixesPsr0;
+            $loader->classMap = ComposerStaticInit8454f2701641055150b8e4897e686c14::$classMap;

         }, null, ClassLoader::class);
     }
--- a/woocommerce-pos/vendor/composer/installed.php
+++ b/woocommerce-pos/vendor/composer/installed.php
@@ -1,9 +1,9 @@
 <?php return array(
     'root' => array(
         'name' => 'wcpos/woocommerce-pos',
-        'pretty_version' => 'v1.9.14',
-        'version' => '1.9.14.0',
-        'reference' => 'c4feb3ef4d026c4126fd134a6f688f7503adfad7',
+        'pretty_version' => 'v1.9.15',
+        'version' => '1.9.15.0',
+        'reference' => '2bb3527d14fb42fb558c2901d0107b3cd91a9ea8',
         'type' => 'wordpress-plugin',
         'install_path' => __DIR__ . '/../../',
         'aliases' => array(),
@@ -89,9 +89,9 @@
             'dev_requirement' => false,
         ),
         'wcpos/woocommerce-pos' => array(
-            'pretty_version' => 'v1.9.14',
-            'version' => '1.9.14.0',
-            'reference' => 'c4feb3ef4d026c4126fd134a6f688f7503adfad7',
+            'pretty_version' => 'v1.9.15',
+            'version' => '1.9.15.0',
+            'reference' => '2bb3527d14fb42fb558c2901d0107b3cd91a9ea8',
             'type' => 'wordpress-plugin',
             'install_path' => __DIR__ . '/../../',
             'aliases' => array(),
--- a/woocommerce-pos/woocommerce-pos.php
+++ b/woocommerce-pos/woocommerce-pos.php
@@ -3,7 +3,7 @@
  * Plugin Name:       WCPOS – Point of Sale for WooCommerce
  * Plugin URI:        https://wordpress.org/plugins/woocommerce-pos/
  * Description:       A simple front-end for taking WooCommerce orders at the Point of Sale. Requires <a href="http://wordpress.org/plugins/woocommerce/">WooCommerce</a>.
- * Version:           1.9.14
+ * Version:           1.9.15
  * Author:            kilbot
  * Author URI:        http://wcpos.com
  * Text Domain:       woocommerce-pos
@@ -25,7 +25,7 @@

 // Define plugin constants (use define() with checks to avoid conflicts when Pro plugin is active).
 if ( ! defined( __NAMESPACE__ . 'VERSION' ) ) {
-	define( __NAMESPACE__ . 'VERSION', '1.9.14' );
+	define( __NAMESPACE__ . 'VERSION', '1.9.15' );
 }
 if ( ! defined( __NAMESPACE__ . 'TRANSLATION_VERSION' ) ) {
 	define( __NAMESPACE__ . 'TRANSLATION_VERSION', '2026.7.8' );

Frequently Asked Questions

Atomic Edge WAF security layer inspecting website traffic.

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
Black & McDonald logo representing Enterprise tier security and support for Atomic Edge WAF.Covenant House Toronto logo featuring a dove and text for Atomic Edge Enterprise planAlzheimer Society Canada logo representing trusted organizations and security partners.University of Toronto logo representing trusted organizations using Atomic Edge WAFSpecsavvers logo, trusted developers and organizations using Atomic Edge securityHarvard Medical School logo representing trusted organizations using Atomic Edge WAF.