-----BEGIN PGP SIGNED MESSAGE-----
Hash: SHA256
NEFARIOUSPLAN-CANONICAL-V1
{"body_md":"## The prefill path runs `sanitize_text_field` for an attribute sink\n\nThe 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()`:\n\n```php\nif(!empty($_GET['cfsPreFill']) && !empty($form['params']['fields'])) {\n foreach($form['params']['fields'] as &$field) {\n $fieldVal = (isset($_GET[$field['name']])\n ? sanitize_text_field($_GET[$field['name']])\n : false);\n $fieldValue = isset($_GET['cfs_'.$field['name']])\n ? sanitize_text_field($_GET['cfs_'.$field['name']])\n : $fieldVal;\n if(isset($field['value']) && isset($field['name']) && $fieldValue !== false) {\n $field['value'] = $fieldValue;\n }\n }\n}\n```\n\n`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=maria@example.com` 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.\n\nWordPress'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 ``.\n\nIt 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.\n\n## The form's HTML is the Twig template\n\nTwo hundred lines later, the same `$field['value']` lands in `generateHtml()`, which is where the destination diverges from the function name:\n\n```php\npublic function generateHtml($form, $params = array()) {\n // ...\n $this->_initTwig();\n // ...\n $form['params']['tpl']['fields'] = $this->generateFields( $form );\n // ...\n return $this->_twig->render(\n ''\n . '
'\n . $form['html']\n . '
',\n array('forms' => $form)\n );\n}\n```\n\n`generateFields()` walks the configured fields and, for each one, calls into `classes/html.php`. The relevant primitive is `htmlCfs::input()`:\n\n```php\nreturn '';\n```\n\nNo `esc_attr`. No `htmlspecialchars`. The attacker's payload concatenates into the HTML literal as ``, 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}}`.\n\nThe Twig environment is constructed by `_initTwig()`:\n\n```php\nprotected function _initTwig() {\n if(!$this->_twig) {\n if(!class_exists('Twig_Autoloader')) {\n require_once(CFS_CLASSES_DIR. 'Twig'. DS. 'Autoloader.php');\n }\n Twig_Autoloader::register();\n $this->_twig = new Twig_Environment(\n new Twig_Loader_String(),\n array('debug' => 0)\n );\n $this->_twig->addFunction(/* adjust_brightness */);\n $this->_twig->addFunction(/* adjust_opacity */);\n $this->_twig->addFunction(/* hex_to_rgba_str */);\n }\n}\n```\n\n`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.\n\n`_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.\n\n```\n{{_self.env.registerUndefinedFilterCallback('system')}}\n{{_self.env.getFilter('whoami')}}\n```\n\nThe 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.\n\n## The PoC's quote workaround is testimony\n\nThe 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.\n\nThe PoC author worked around the constraint by reusing the prefill mechanism the SSTI lives in:\n\n```python\npayload = (\n \"{{_self.env.registerUndefinedFilterCallback(\"\n \"forms.params.fields.1.value)}}\"\n \"{{_self.env.getFilter(forms.params.fields.2.value)}}\"\n)\n\nparams = {\n \"cfsPreFill\": \"1\",\n field_name: payload, # first targeted field\n \"last_name\": \"system\", # writes into fields[1].value\n \"email\": command # writes into fields[2].value\n}\n```\n\n`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.\n\nThis 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.\n\n## The patch is one `str_replace`. Twig 1.16.0 stays.\n\nThe 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:\n\n```diff\n foreach ($form['params']['fields'] as &$field) {\n $fieldVal = isset($_GET[$field['name']])\n ? sanitize_text_field($_GET[$field['name']]) : false;\n $fieldValue = isset($_GET['cfs_' . $field['name']])\n ? sanitize_text_field($_GET['cfs_' . $field['name']]) : $fieldVal;\n if (isset($field['value']) && isset($field['name']) && $fieldValue !== false) {\n+ // Escape Twig template delimiters to prevent Server-Side Template Injection (SSTI/RCE).\n+ // sanitize_text_field() does not strip curly braces, so an attacker can inject\n+ // Twig expressions (e.g. {{_self.env.registerUndefinedFilterCallback(...)}})\n+ // that get evaluated by Twig_Loader_String at render time.\n+ $fieldValue = str_replace(['{', '}'], ['{', '}'], $fieldValue);\n $field['value'] = $fieldValue;\n }\n }\n```\n\nThe fix replaces `{` with `{` and `}` with `}` 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.\n\nWhat the patch does not touch:\n\n- `_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.\n- `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.\n- 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.\n- `htmlCfs::input()` in `classes/html.php` still emits `''` 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.\n\n## The pattern: an HTML-attribute sanitizer in front of a string-loader template engine\n\nThis is [content-is-command](/patterns/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](/posts/tandoor-recipes-bleach-is-not-a-jinja-sandbox), 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.\"\n\nSupsystic 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.\n\nThe fix in 1.8.0 is the same shape Tandoor would have taken if Tandoor's maintainers had added `replace('{{','{{').replace('}}','}}')` 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.\n\n## The charge\n\nSupsystic 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.\n\nThe 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.\n\nPoC: [shootcannon/CVE-2026-4257](https://github.com/shootcannon/CVE-2026-4257).","closing_line":"The 1.8.0 patch escaped the braces. The interpreter the braces were going to reach is from 2014.","hook_md":"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`.\n\nThe 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.\n\nThe 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.","post_id":268,"slug":"supsystic-prefill-renders-through-twig-loader-string","title":"CVE-2026-4257: Supsystic Sanitizes Prefill For HTML, Renders It Through Twig_Loader_String","type":"initial","unreadable_sentence":"`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."}
-----BEGIN PGP SIGNATURE-----
iHUEARYIAB0WIQRf0htP5+SjynlxywneZjl4jgkQJgUCarqfWwAKCRDeZjl4jgkQ
JmMnAQDqRnZwhPAmnn8Hiz1zZMPaF+BRR44X1ePBGx9GqUBs+wD8DI/hI4TQ6oGy
HzlxvxeNKmydfk6pwOQtkZwxwZfHVwQ=
=niwN
-----END PGP SIGNATURE-----