use serde::Deserialize;
use std::collections::BTreeSet;
#[derive(Debug, Clone, Deserialize)]
struct Engine {
#[allow(dead_code)]
name: String,
delimiters: Vec<String>,
#[allow(dead_code)]
description: String,
#[allow(dead_code)]
category: String,
#[serde(default)]
payloads: Vec<Payload>,
}
#[derive(Debug, Clone, Deserialize)]
struct Payload {
#[allow(dead_code)]
#[serde(rename = "type")]
payload_type: String,
payload: String,
#[allow(dead_code)]
description: String,
}
#[derive(Debug, Clone, Deserialize)]
struct Polyglot {
#[allow(dead_code)]
name: String,
#[allow(dead_code)]
description: String,
payload: String,
}
#[derive(Debug, Clone, Deserialize)]
struct TemplateRules {
#[serde(default)]
engine: Vec<Engine>,
#[serde(default)]
polyglot: Vec<Polyglot>,
}
impl Default for TemplateRules {
fn default() -> Self {
Self {
engine: vec![Engine {
name: "jinja2".into(),
delimiters: vec!["{{".into(), "}}".into(), "{%".into(), "%}".into()],
description: "Python Jinja2".into(),
category: "python".into(),
payloads: vec![Payload {
payload_type: "expression".into(),
payload: "{{7*7}}".into(),
description: "Basic arithmetic".into(),
}],
}],
polyglot: vec![Polyglot {
name: "polyglot_probe".into(),
description: "Universal probe".into(),
payload: "${{<%[%'\"}}%\\.".into(),
}],
}
}
}
const TEMPLATE_RULES_TOML: &str = include_str!("../../rules/templates.toml");
fn get_rules() -> &'static TemplateRules {
use std::sync::OnceLock;
static RULES: OnceLock<TemplateRules> = OnceLock::new();
RULES.get_or_init(|| {
toml::from_str(TEMPLATE_RULES_TOML).unwrap_or_else(|e| {
tracing::warn!(error = %e, "invalid TOML in rules/templates.toml");
TemplateRules::default()
})
})
}
#[must_use]
pub fn mutate(payload: &str) -> Vec<String> {
if payload.is_empty() || !detect_type(payload) {
return Vec::new();
}
if is_structured_ssti(payload) {
return structured_ssti_mutate(payload);
}
let mut results = BTreeSet::new();
let rules = get_rules();
for engine in &rules.engine {
for p in &engine.payloads {
results.insert(p.payload.clone());
}
}
for polyglot in &rules.polyglot {
results.insert(polyglot.payload.clone());
}
if payload.contains("{{") {
results.insert(payload.replace("{{", "{{7*7}}{{"));
}
if payload.contains("${") {
results.insert(payload.replace("${", "${7*7}${"));
}
if payload.contains("<%") {
results.insert(payload.replace("<%", "<%= 7*7 %><%"));
}
if payload.contains("#{") {
results.insert(payload.replace("#{", "#{7*7}#{"));
}
if payload.contains("@{") {
results.insert(payload.replace("@{", "@{7*7}@{"));
}
results.remove(payload);
results.into_iter().collect()
}
#[must_use]
pub fn detect_type(payload: &str) -> bool {
let lower = payload.to_ascii_lowercase();
let rules = get_rules();
for engine in &rules.engine {
for delimiter in &engine.delimiters {
if delimiter.chars().count() < 2 {
continue; }
if payload.contains(delimiter.as_str()) {
return true;
}
}
}
if payload.contains("{$") || lower.contains("{php}") || lower.contains("{smarty") {
return true;
}
if lower.contains("#set(")
|| lower.contains("#if(")
|| lower.contains("#foreach(")
|| lower.contains("#parse(")
|| lower.contains("#evaluate(")
|| lower.contains("#include(")
|| lower.contains("#macro(")
|| lower.contains("#{")
|| lower.contains("$class.")
|| lower.contains("$runtime.")
|| lower.contains("$context.")
{
return true;
}
if lower.contains("th:") || lower.contains("th-") {
return true;
}
if lower.contains("- require(") || lower.contains("= global.process") {
return true;
}
if lower.contains("@php") || lower.contains("@endphp") || lower.contains("{!!") {
return true;
}
if lower.contains("<%!") || lower.contains("<%def") || lower.contains("<%namespace") {
return true;
}
if lower.contains("{{#") || lower.contains("{{/") || lower.contains("{{>") {
return true;
}
if lower.contains("<%-") || lower.contains("<%_") || lower.contains("_%>") {
return true;
}
if lower.contains("$!{") || lower.contains("#macro") || lower.contains("#parse") {
return true;
}
false
}
#[must_use]
pub fn supported_engines() -> Vec<&'static str> {
let rules = get_rules();
rules.engine.iter().map(|e| e.name.as_str()).collect()
}
#[must_use]
pub fn get_engine_payloads(engine_name: &str) -> Vec<String> {
let rules = get_rules();
rules
.engine
.iter()
.find(|e| e.name.eq_ignore_ascii_case(engine_name))
.map(|e| e.payloads.iter().map(|p| p.payload.clone()).collect())
.unwrap_or_default()
}
pub(crate) fn is_structured_ssti(payload: &str) -> bool {
let lc = payload.to_ascii_lowercase();
const STRUCTURED: &[&str] = &[
".__class__",
"__globals__",
"__subclasses__",
"__mro__",
"__init__",
"__builtins__",
"__import__",
"''.__",
"().__",
"[].__",
"popen",
"subprocess",
"os.system",
"system(",
".read()",
"lipsum",
"cycler",
"request.application",
"config.items",
"config.__",
"|attr(",
"getruntime",
"runtime",
"processbuilder",
"freemarker.template.utility.execute",
"execute\")",
"t(java",
"t(org",
"getclass(",
"reflect",
"import os",
"exec(",
"eval(",
"scriptengine",
"javax.script",
"new (\"",
"#set($",
"$class.inspect",
"/etc/passwd",
"cat /",
"whoami",
"id;",
"curl ",
"wget ",
];
STRUCTURED.iter().any(|m| lc.contains(m))
}
fn extract_template_expr(payload: &str) -> Option<String> {
for (open, close) in [
("{{", "}}"),
("{%", "%}"),
("${", "}"),
("#{", "}"),
("<%", "%>"),
("@{", "}"),
] {
if let Some(o) = payload.find(open) {
let after = &payload[o + open.len()..];
if let Some(c) = after.find(close) {
let mut expr = after[..c].trim();
expr = expr.trim_start_matches('=').trim();
if !expr.is_empty() {
return Some(expr.to_string());
}
}
}
}
None
}
fn structured_ssti_mutate(payload: &str) -> Vec<String> {
let mut out: BTreeSet<String> = BTreeSet::new();
out.insert(payload.trim().to_string());
if let Some(e) = extract_template_expr(payload) {
for v in [
format!("{{{{{e}}}}}"), format!("{{{{ {e} }}}}"), format!("{{{{\t{e}}}}}"), format!("{{{{{e}|safe}}}}"), format!("{{%print({e})%}}"), format!("${{{e}}}"), format!("#{{{e}}}"), format!("<%= {e} %>"), format!("<%={e}%>"), format!("{{{e}}}"), format!("${{{{{e}}}}}"), format!("#set($x={e})$x"), format!("{{{{{e}}}}}\u{200b}"), ] {
out.insert(v);
}
}
if let Some(e) = extract_template_expr(payload) {
let markers: Vec<String> = e
.to_ascii_lowercase()
.split(|c: char| !c.is_ascii_alphanumeric())
.filter(|t| t.len() >= 4 && t.chars().any(|c| c.is_ascii_alphabetic()))
.map(str::to_string)
.collect();
if !markers.is_empty() {
out.retain(|v| {
let lc = v.to_ascii_lowercase();
markers.iter().any(|m| lc.contains(m.as_str()))
});
}
}
out.into_iter().collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn detects_jinja_syntax() {
assert!(detect_type("{{ user.name }}"));
assert!(detect_type("{% if user %}"));
}
#[test]
fn detects_twig_syntax() {
assert!(detect_type("{{ 7*7 }}"));
assert!(detect_type("{% for item in items %}"));
}
#[test]
fn detects_freemarker_syntax() {
assert!(detect_type("${user}"));
assert!(detect_type("<#assign x=1>"));
}
#[test]
fn detects_pebble_syntax() {
assert!(detect_type("{{ user }}"));
}
#[test]
fn detects_velocity_syntax() {
assert!(detect_type("#{user}"));
assert!(detect_type("#set($x=1)"));
assert!(detect_type("$class.inspect"));
}
#[test]
fn detects_erb_syntax() {
assert!(detect_type("<%= 7*7 %>"));
assert!(detect_type("<% if true %>"));
}
#[test]
fn detects_smarty_syntax() {
assert!(detect_type("{$user}"));
assert!(detect_type("{php}echo 1{/php}"));
}
#[test]
fn detects_thymeleaf_syntax() {
assert!(detect_type("th:text=\"${user}\""));
assert!(detect_type("th:value=\"${name}\""));
}
#[test]
fn detects_pug_syntax() {
assert!(detect_type("#{user}"));
assert!(detect_type("!{html}"));
assert!(detect_type("- console.log(1)"));
}
#[test]
fn detects_nunjucks_syntax() {
assert!(detect_type("{{ user }}"));
assert!(detect_type("{% extends \"base.html\" %}"));
}
#[test]
fn detects_mako_syntax() {
assert!(detect_type("${user}"));
assert!(detect_type("<% import os %>"));
}
#[test]
fn detects_blade_syntax() {
assert!(detect_type("{{ $user }}"));
assert!(detect_type("@php echo 1; @endphp"));
assert!(detect_type("{!! $html !!}"));
}
#[test]
fn detects_liquid_syntax() {
assert!(detect_type("{{ user }}"));
assert!(detect_type("{% assign x = 1 %}"));
}
#[test]
fn detects_handlebars_syntax() {
assert!(detect_type("{{ user }}"));
assert!(detect_type("{{{ unescaped }}}"));
assert!(detect_type("{{#each items}}{{/each}}"));
}
#[test]
fn detects_ejs_syntax() {
assert!(detect_type("<%= user %>"));
assert!(detect_type("<% if (true) { %>"));
assert!(detect_type("<%- include('header') %>"));
}
#[test]
fn rejects_plain_text() {
assert!(!detect_type("ordinary content only"));
assert!(!detect_type("Hello World"));
assert!(!detect_type("No template here"));
}
#[test]
fn generates_jinja_variants() {
let mutations = mutate("{{user}}");
assert!(mutations.iter().any(|item| item == "{{7*7}}"));
assert!(
mutations
.iter()
.any(|item| item.contains("__class__.__mro__"))
);
assert!(mutations.iter().any(|item| item.contains("range(10)")));
}
#[test]
fn generates_freemarker_variants() {
let mutations = mutate("${user}");
assert!(mutations.iter().any(|item| item == "${7*7}"));
assert!(mutations.iter().any(|item| item.contains("<#assign")));
assert!(mutations.iter().any(|item| item.contains("?new()")));
}
#[test]
fn generates_pebble_variants() {
let mutations = mutate("{{user}}");
assert!(
mutations
.iter()
.any(|item| item.contains("getClass().forName"))
);
}
#[test]
fn generates_erb_variants() {
let mutations = mutate("<%= user %>");
assert!(mutations.iter().any(|item| item == "<%= 7*7 %>"));
assert!(mutations.iter().any(|item| item.contains("system('id')")));
}
#[test]
fn generates_smarty_variants() {
let mutations = mutate("${user}");
assert!(
mutations
.iter()
.any(|item| item.contains("{php}echo 'test';{/php}"))
);
}
#[test]
fn generates_velocity_variants() {
let mutations = mutate("#{user}");
assert!(mutations.iter().any(|item| item.contains("#set($x=7*7)")));
assert!(mutations.iter().any(|item| item.contains("$class.inspect")));
}
#[test]
fn generates_thymeleaf_variants() {
let mutations = mutate("th:text\"");
assert!(
mutations
.iter()
.any(|item| item.contains("th:text=\"${7*7}\""))
);
}
#[test]
fn generates_pug_variants() {
let mutations = mutate("#{user}");
assert!(
mutations
.iter()
.any(|item| item.contains("require('child_process')"))
);
}
#[test]
fn generates_nunjucks_variants() {
let mutations = mutate("{{user}}");
assert!(
mutations
.iter()
.any(|item| item.contains("range.constructor"))
);
}
#[test]
fn generates_mako_variants() {
let mutations = mutate("${user}");
assert!(
mutations
.iter()
.any(|item| item.contains("__import__('os')"))
);
}
#[test]
fn generates_blade_variants() {
let mutations = mutate("{{user}}");
assert!(
mutations
.iter()
.any(|item| item.contains("@php") || item == "{{7*7}}")
);
}
#[test]
fn generates_liquid_variants() {
let mutations = mutate("{{user}}");
assert!(
mutations
.iter()
.any(|item| item.contains("inspect") || item == "{{7*7}}")
);
}
#[test]
fn generates_handlebars_variants() {
let mutations = mutate("{{user}}");
assert!(
mutations
.iter()
.any(|item| item.contains("constructor") || item == "{{7*7}}")
);
}
#[test]
fn generates_ejs_variants() {
let mutations = mutate("<%= user %>");
assert!(
mutations
.iter()
.any(|item| item.contains("require('child_process')"))
);
}
#[test]
fn generates_polyglot_variant() {
let mutations = mutate("{{user}}");
assert!(mutations.iter().any(|item| item == "${{<%[%'\"}}%\\."));
}
#[test]
fn empty_payload_returns_empty() {
let mutations = mutate("");
assert!(mutations.is_empty());
}
#[test]
fn non_template_payload_returns_empty() {
let mutations = mutate("hello world");
assert!(mutations.is_empty());
}
#[test]
fn supported_engines_listed() {
let engines = supported_engines();
assert!(engines.contains(&"jinja2"));
assert!(engines.contains(&"twig"));
assert!(engines.contains(&"freemarker"));
assert!(engines.contains(&"velocity"));
assert!(engines.contains(&"erb"));
assert!(engines.contains(&"smarty"));
assert!(engines.contains(&"thymeleaf"));
assert!(engines.contains(&"pug"));
assert!(engines.contains(&"nunjucks"));
assert!(engines.contains(&"mako"));
assert!(engines.contains(&"blade"));
assert!(engines.contains(&"liquid"));
assert!(engines.contains(&"handlebars"));
assert!(engines.contains(&"ejs"));
}
#[test]
fn get_engine_payloads_returns_correct_payloads() {
let jinja_payloads = get_engine_payloads("jinja2");
assert!(!jinja_payloads.is_empty());
assert!(jinja_payloads.iter().any(|p| p.contains("7*7")));
assert!(jinja_payloads.iter().any(|p| p.contains("__class__")));
let unknown = get_engine_payloads("nonexistent");
assert!(unknown.is_empty());
}
#[test]
fn context_aware_mutations_work() {
let jinja = mutate("{{user}}");
assert!(jinja.iter().any(|p| p.contains("{{7*7}}{{")));
let freemarker = mutate("${user}");
assert!(freemarker.iter().any(|p| p.contains("${7*7}${")));
let erb = mutate("<%= user %>");
assert!(erb.iter().any(|p| p.contains("<%= 7*7 %><%")));
let velocity = mutate("#{user}");
assert!(velocity.iter().any(|p| p.contains("#{7*7}#{")));
}
}