834 lines
16 KiB
Python
834 lines
16 KiB
Python
#!/usr/bin/env python3
|
|
# -*- coding: utf-8 -*-
|
|
|
|
"""
|
|
Общая бизнес-логика Hashcat.
|
|
|
|
Этот модуль используется одновременно:
|
|
• серверным Reports;
|
|
• CLI tools/hashcat.py.
|
|
|
|
Модуль не открывает соединение с SQLite самостоятельно.
|
|
|
|
Соединение передаётся вызывающим кодом и обслуживается
|
|
существующим reports.database.
|
|
"""
|
|
|
|
from datetime import datetime, timezone
|
|
from pathlib import Path
|
|
import re
|
|
|
|
|
|
# ============================================================
|
|
# Constants
|
|
# ============================================================
|
|
|
|
MAC_PATTERN = re.compile(
|
|
r"^[0-9a-fA-F]{12}$"
|
|
)
|
|
|
|
SUPPORTED_HANDSHAKE_TYPES = {
|
|
"WPA*01",
|
|
"WPA*02",
|
|
}
|
|
|
|
|
|
# ============================================================
|
|
# Export filename
|
|
# ============================================================
|
|
|
|
def sanitize_export_query(
|
|
query,
|
|
):
|
|
value = str(
|
|
query or ""
|
|
).strip().lower()
|
|
|
|
value = re.sub(
|
|
r"[^a-z0-9]+",
|
|
"-",
|
|
value
|
|
)
|
|
|
|
value = value.strip(
|
|
"-"
|
|
)
|
|
|
|
if not value:
|
|
value = "all"
|
|
|
|
return value[
|
|
:80
|
|
]
|
|
|
|
|
|
def build_export_filename(
|
|
export_type,
|
|
query="",
|
|
):
|
|
timestamp = datetime.now(
|
|
timezone.utc
|
|
).strftime(
|
|
"%Y%m%d-%H%M%S"
|
|
)
|
|
|
|
if export_type == "all":
|
|
|
|
return (
|
|
"wifi-gps-mapper-all-"
|
|
f"{timestamp}.hc22000"
|
|
)
|
|
|
|
if export_type == "search":
|
|
|
|
safe_query = sanitize_export_query(
|
|
query
|
|
)
|
|
|
|
return (
|
|
"wifi-gps-mapper-search-"
|
|
f"{safe_query}-"
|
|
f"{timestamp}.hc22000"
|
|
)
|
|
|
|
raise ValueError(
|
|
f"Unknown export type: {export_type}"
|
|
)
|
|
|
|
|
|
# ============================================================
|
|
# Hash export
|
|
# ============================================================
|
|
|
|
def _build_hash_export_from_rows(
|
|
rows,
|
|
):
|
|
hashes = [
|
|
str(
|
|
row["hash22000"]
|
|
)
|
|
for row in rows
|
|
if row["hash22000"] is not None
|
|
and str(
|
|
row["hash22000"]
|
|
).strip()
|
|
]
|
|
|
|
if not hashes:
|
|
return ""
|
|
|
|
return (
|
|
"\n".join(
|
|
hashes
|
|
)
|
|
+
|
|
"\n"
|
|
)
|
|
|
|
|
|
def get_all_hashes(
|
|
conn,
|
|
):
|
|
cursor = conn.execute(
|
|
"""
|
|
SELECT DISTINCT
|
|
h.hash22000
|
|
FROM handshakes AS h
|
|
WHERE
|
|
h.hash22000 IS NOT NULL
|
|
AND TRIM(h.hash22000) != ''
|
|
AND NOT EXISTS (
|
|
SELECT 1
|
|
FROM credentials AS c
|
|
WHERE c.handshake_id = h.id
|
|
)
|
|
ORDER BY
|
|
h.hash22000
|
|
"""
|
|
)
|
|
|
|
return cursor.fetchall()
|
|
|
|
|
|
def build_all_hash_export(
|
|
conn,
|
|
):
|
|
rows = get_all_hashes(
|
|
conn
|
|
)
|
|
|
|
return _build_hash_export_from_rows(
|
|
rows
|
|
)
|
|
|
|
|
|
# ============================================================
|
|
# Database statistics
|
|
# ============================================================
|
|
|
|
def get_export_statistics(
|
|
conn,
|
|
):
|
|
rows = get_all_hashes(
|
|
conn
|
|
)
|
|
|
|
statistics = {
|
|
"exported_hashes": len(rows),
|
|
"wpa01": 0,
|
|
"wpa02": 0,
|
|
}
|
|
|
|
for row in rows:
|
|
|
|
hash_value = str(
|
|
row["hash22000"]
|
|
)
|
|
|
|
if hash_value.startswith(
|
|
"WPA*01*"
|
|
):
|
|
statistics["wpa01"] += 1
|
|
|
|
elif hash_value.startswith(
|
|
"WPA*02*"
|
|
):
|
|
statistics["wpa02"] += 1
|
|
|
|
return statistics
|
|
|
|
def get_database_statistics(
|
|
conn,
|
|
):
|
|
statistics = {}
|
|
|
|
cursor = conn.execute(
|
|
"""
|
|
SELECT COUNT(*)
|
|
FROM access_points
|
|
"""
|
|
)
|
|
|
|
statistics["access_points"] = cursor.fetchone()[0]
|
|
|
|
cursor = conn.execute(
|
|
"""
|
|
SELECT COUNT(*)
|
|
FROM handshakes
|
|
"""
|
|
)
|
|
|
|
statistics["handshakes"] = cursor.fetchone()[0]
|
|
|
|
cursor = conn.execute(
|
|
"""
|
|
SELECT COUNT(*)
|
|
FROM handshakes
|
|
WHERE
|
|
hash22000 IS NOT NULL
|
|
AND TRIM(hash22000) != ''
|
|
"""
|
|
)
|
|
|
|
statistics["hashes"] = cursor.fetchone()[0]
|
|
|
|
cursor = conn.execute(
|
|
"""
|
|
SELECT COUNT(DISTINCT hash22000)
|
|
FROM handshakes
|
|
WHERE
|
|
hash22000 IS NOT NULL
|
|
AND TRIM(hash22000) != ''
|
|
"""
|
|
)
|
|
|
|
statistics["unique_hashes"] = cursor.fetchone()[0]
|
|
|
|
cursor = conn.execute(
|
|
"""
|
|
SELECT COUNT(*)
|
|
FROM credentials
|
|
"""
|
|
)
|
|
|
|
statistics["credentials"] = cursor.fetchone()[0]
|
|
|
|
cursor = conn.execute(
|
|
"""
|
|
SELECT COUNT(DISTINCT c.access_point_id)
|
|
FROM credentials AS c
|
|
JOIN handshakes AS h
|
|
ON h.id = c.handshake_id
|
|
"""
|
|
)
|
|
|
|
statistics["cracked_access_points"] = cursor.fetchone()[0]
|
|
|
|
return statistics
|
|
|
|
# ============================================================
|
|
# Hashcat --show parser
|
|
# ============================================================
|
|
|
|
def _normalize_mac(
|
|
value,
|
|
):
|
|
value = str(
|
|
value or ""
|
|
).strip().lower()
|
|
|
|
value = value.replace(
|
|
":",
|
|
""
|
|
)
|
|
|
|
value = value.replace(
|
|
"-",
|
|
""
|
|
)
|
|
|
|
return value
|
|
|
|
def _normalize_hashcat_essid(
|
|
value,
|
|
):
|
|
value = str(
|
|
value or ""
|
|
).strip()
|
|
|
|
if (
|
|
value.startswith("$HEX[")
|
|
and value.endswith("]")
|
|
):
|
|
encoded = value[5:-1]
|
|
|
|
if not encoded:
|
|
return ""
|
|
|
|
if (
|
|
len(encoded) % 2 != 0
|
|
or re.fullmatch(
|
|
r"[0-9a-fA-F]+",
|
|
encoded
|
|
) is None
|
|
):
|
|
raise ValueError(
|
|
"ESSID $HEX value must be hexadecimal."
|
|
)
|
|
|
|
return bytes.fromhex(
|
|
encoded
|
|
).hex()
|
|
|
|
return value.encode(
|
|
"utf-8"
|
|
).hex()
|
|
|
|
def _parse_handshake_hash(
|
|
value,
|
|
):
|
|
value = str(
|
|
value or ""
|
|
).strip()
|
|
|
|
if not value:
|
|
raise ValueError(
|
|
"HASH is empty."
|
|
)
|
|
|
|
parts = value.split(
|
|
"*"
|
|
)
|
|
|
|
if len(parts) < 6:
|
|
raise ValueError(
|
|
"HASH is not a valid WPA hash."
|
|
)
|
|
|
|
handshake_type = (
|
|
f"{parts[0]}*{parts[1]}"
|
|
)
|
|
|
|
if handshake_type not in SUPPORTED_HANDSHAKE_TYPES:
|
|
raise ValueError(
|
|
"Unsupported handshake type: "
|
|
f"{handshake_type}."
|
|
)
|
|
|
|
hash_value = parts[2].strip().lower()
|
|
ap_bssid = _normalize_mac(
|
|
parts[3]
|
|
)
|
|
client_mac = _normalize_mac(
|
|
parts[4]
|
|
)
|
|
essid = parts[5].strip().lower()
|
|
|
|
if not hash_value:
|
|
raise ValueError(
|
|
"HASH value is empty."
|
|
)
|
|
|
|
if (
|
|
len(hash_value) != 32
|
|
or re.fullmatch(
|
|
r"[0-9a-f]{32}",
|
|
hash_value
|
|
) is None
|
|
):
|
|
raise ValueError(
|
|
"HASH value must be 32 hexadecimal characters."
|
|
)
|
|
|
|
if not MAC_PATTERN.fullmatch(
|
|
ap_bssid
|
|
):
|
|
raise ValueError(
|
|
"AP_BSSID is not a valid MAC address."
|
|
)
|
|
|
|
if not MAC_PATTERN.fullmatch(
|
|
client_mac
|
|
):
|
|
raise ValueError(
|
|
"CLIENT_MAC is not a valid MAC address."
|
|
)
|
|
|
|
if essid and (
|
|
len(essid) % 2 != 0
|
|
or re.fullmatch(
|
|
r"[0-9a-fA-F]+",
|
|
essid
|
|
) is None
|
|
):
|
|
raise ValueError(
|
|
"ESSID must be hexadecimal."
|
|
)
|
|
|
|
return {
|
|
"handshake_type": handshake_type,
|
|
"hash": hash_value.lower(),
|
|
"ap_bssid": ap_bssid,
|
|
"client_mac": client_mac,
|
|
"essid": essid,
|
|
}
|
|
|
|
|
|
def parse_hashcat_show_line(
|
|
line,
|
|
line_number,
|
|
):
|
|
raw = str(
|
|
line
|
|
).rstrip(
|
|
"\r\n"
|
|
)
|
|
|
|
if not raw.strip():
|
|
raise ValueError(
|
|
"Empty line."
|
|
)
|
|
|
|
parts = raw.split(
|
|
":",
|
|
4
|
|
)
|
|
|
|
if len(parts) != 5:
|
|
raise ValueError(
|
|
"Expected format: "
|
|
"HASH:AP_BSSID:CLIENT_MAC:ESSID:PASSWORD."
|
|
)
|
|
|
|
hash_value = parts[0].strip().lower()
|
|
ap_bssid = _normalize_mac(
|
|
parts[1]
|
|
)
|
|
client_mac = _normalize_mac(
|
|
parts[2]
|
|
)
|
|
essid = parts[3].strip()
|
|
password = parts[4]
|
|
|
|
if not hash_value:
|
|
raise ValueError(
|
|
"HASH is empty."
|
|
)
|
|
|
|
if (
|
|
len(hash_value) != 32
|
|
or re.fullmatch(
|
|
r"[0-9a-f]{32}",
|
|
hash_value
|
|
) is None
|
|
):
|
|
raise ValueError(
|
|
"HASH value must be 32 hexadecimal characters."
|
|
)
|
|
|
|
if not MAC_PATTERN.fullmatch(
|
|
ap_bssid
|
|
):
|
|
raise ValueError(
|
|
"AP_BSSID is not a valid MAC address."
|
|
)
|
|
|
|
if not MAC_PATTERN.fullmatch(
|
|
client_mac
|
|
):
|
|
raise ValueError(
|
|
"CLIENT_MAC is not a valid MAC address."
|
|
)
|
|
|
|
essid_hex = _normalize_hashcat_essid(
|
|
essid
|
|
)
|
|
|
|
return {
|
|
"line_number": line_number,
|
|
"hash22000": hash_value,
|
|
"hash": hash_value,
|
|
"ap_bssid": ap_bssid,
|
|
"client_mac": client_mac,
|
|
"essid": essid_hex,
|
|
"password": password,
|
|
}
|
|
|
|
|
|
# ============================================================
|
|
# Handshake matching
|
|
# ============================================================
|
|
|
|
def find_matching_handshake(
|
|
conn,
|
|
parsed,
|
|
):
|
|
from reports.database import query_all
|
|
|
|
rows = query_all(
|
|
conn,
|
|
"""
|
|
SELECT
|
|
h.id,
|
|
h.access_point_id,
|
|
h.hash22000
|
|
FROM handshakes AS h
|
|
WHERE
|
|
h.hash22000 IS NOT NULL
|
|
AND TRIM(h.hash22000) != ''
|
|
ORDER BY
|
|
h.id
|
|
"""
|
|
)
|
|
|
|
for row in rows:
|
|
|
|
try:
|
|
handshake = _parse_handshake_hash(
|
|
row["hash22000"]
|
|
)
|
|
except ValueError:
|
|
continue
|
|
|
|
if (
|
|
handshake["hash"]
|
|
== parsed["hash"]
|
|
and handshake["ap_bssid"]
|
|
== parsed["ap_bssid"]
|
|
and handshake["client_mac"]
|
|
== parsed["client_mac"]
|
|
):
|
|
return row
|
|
|
|
return None
|
|
|
|
|
|
# ============================================================
|
|
# Credential operations
|
|
# ============================================================
|
|
|
|
def credential_exists(
|
|
conn,
|
|
access_point_id,
|
|
handshake_id,
|
|
password,
|
|
):
|
|
from reports.database import query_one
|
|
|
|
row = query_one(
|
|
conn,
|
|
"""
|
|
SELECT id
|
|
FROM credentials
|
|
WHERE
|
|
access_point_id = ?
|
|
AND handshake_id = ?
|
|
AND password = ?
|
|
LIMIT 1
|
|
""",
|
|
(
|
|
access_point_id,
|
|
handshake_id,
|
|
password,
|
|
)
|
|
)
|
|
|
|
return row is not None
|
|
|
|
|
|
def add_credential(
|
|
conn,
|
|
access_point_id,
|
|
handshake_id,
|
|
password,
|
|
):
|
|
from reports.database import execute
|
|
|
|
now = datetime.now(
|
|
timezone.utc
|
|
).isoformat().replace(
|
|
"+00:00",
|
|
"Z"
|
|
)
|
|
|
|
cursor = execute(
|
|
conn,
|
|
"""
|
|
INSERT INTO credentials
|
|
(
|
|
access_point_id,
|
|
handshake_id,
|
|
password,
|
|
source,
|
|
created_at
|
|
)
|
|
VALUES
|
|
(
|
|
?,
|
|
?,
|
|
?,
|
|
?,
|
|
?
|
|
)
|
|
""",
|
|
(
|
|
access_point_id,
|
|
handshake_id,
|
|
password,
|
|
"hashcat",
|
|
now,
|
|
)
|
|
)
|
|
|
|
execute(
|
|
conn,
|
|
"""
|
|
UPDATE access_points
|
|
SET
|
|
is_cracked = 1
|
|
WHERE
|
|
id = ?
|
|
""",
|
|
(
|
|
access_point_id,
|
|
)
|
|
)
|
|
|
|
return cursor.lastrowid
|
|
|
|
# ============================================================
|
|
# Import validation
|
|
# ============================================================
|
|
|
|
def validate_hashcat_show_file(
|
|
conn,
|
|
lines,
|
|
):
|
|
statistics = {
|
|
"total_lines": 0,
|
|
"valid_lines": 0,
|
|
"invalid_lines": 0,
|
|
"matched_handshakes": 0,
|
|
"missing_handshakes": 0,
|
|
"new_credentials": 0,
|
|
"duplicate_credentials": 0,
|
|
"already_existing": 0,
|
|
"errors": [],
|
|
"missing": [],
|
|
"items": [],
|
|
}
|
|
|
|
seen = set()
|
|
seen_credentials = set()
|
|
|
|
for line_number, line in enumerate(
|
|
lines,
|
|
start=1
|
|
):
|
|
statistics["total_lines"] += 1
|
|
|
|
try:
|
|
parsed = parse_hashcat_show_line(
|
|
line,
|
|
line_number
|
|
)
|
|
|
|
key = (
|
|
parsed["hash"],
|
|
parsed["ap_bssid"],
|
|
parsed["client_mac"],
|
|
parsed["essid"],
|
|
parsed["password"],
|
|
)
|
|
|
|
if key in seen:
|
|
raise ValueError(
|
|
"Duplicate line in input file."
|
|
)
|
|
|
|
seen.add(
|
|
key
|
|
)
|
|
|
|
handshake = find_matching_handshake(
|
|
conn,
|
|
parsed
|
|
)
|
|
|
|
if handshake is None:
|
|
statistics["missing_handshakes"] += 1
|
|
|
|
statistics["missing"].append(
|
|
{
|
|
"line": line_number,
|
|
"hash":
|
|
parsed["hash"],
|
|
"ap_bssid":
|
|
parsed["ap_bssid"],
|
|
"client_mac":
|
|
parsed["client_mac"],
|
|
"essid":
|
|
parsed["essid"],
|
|
"password":
|
|
parsed["password"],
|
|
}
|
|
)
|
|
|
|
continue
|
|
|
|
statistics["matched_handshakes"] += 1
|
|
|
|
credential_key = (
|
|
handshake["access_point_id"],
|
|
handshake["id"],
|
|
parsed["password"],
|
|
)
|
|
|
|
if credential_key in seen_credentials:
|
|
|
|
statistics["duplicate_credentials"] += 1
|
|
status = "Duplicate credential"
|
|
|
|
else:
|
|
|
|
seen_credentials.add(
|
|
credential_key
|
|
)
|
|
|
|
exists = credential_exists(
|
|
conn,
|
|
handshake["access_point_id"],
|
|
handshake["id"],
|
|
parsed["password"]
|
|
)
|
|
|
|
if exists:
|
|
statistics["already_existing"] += 1
|
|
status = "Already exists"
|
|
|
|
else:
|
|
statistics["new_credentials"] += 1
|
|
status = "New credential"
|
|
|
|
statistics["valid_lines"] += 1
|
|
|
|
statistics["items"].append(
|
|
{
|
|
"line": line_number,
|
|
"access_point_id":
|
|
handshake["access_point_id"],
|
|
"handshake_id":
|
|
handshake["id"],
|
|
"password":
|
|
parsed["password"],
|
|
"status": status,
|
|
}
|
|
)
|
|
|
|
except ValueError as error:
|
|
|
|
statistics["invalid_lines"] += 1
|
|
|
|
statistics["errors"].append(
|
|
{
|
|
"line": line_number,
|
|
"reason": str(
|
|
error
|
|
),
|
|
}
|
|
)
|
|
|
|
return statistics
|
|
|
|
# ============================================================
|
|
# Atomic import
|
|
# ============================================================
|
|
|
|
def import_hashcat_show(
|
|
conn,
|
|
lines,
|
|
):
|
|
lines = list(
|
|
lines
|
|
)
|
|
|
|
validation = validate_hashcat_show_file(
|
|
conn,
|
|
lines
|
|
)
|
|
|
|
if validation["errors"]:
|
|
|
|
return {
|
|
**validation,
|
|
"committed": False,
|
|
"result": "Rejected",
|
|
}
|
|
|
|
try:
|
|
|
|
for item in validation["items"]:
|
|
|
|
if item["status"] != "New credential":
|
|
continue
|
|
|
|
add_credential(
|
|
conn,
|
|
item["access_point_id"],
|
|
item["handshake_id"],
|
|
item["password"]
|
|
)
|
|
|
|
conn.commit()
|
|
|
|
return {
|
|
**validation,
|
|
"committed": True,
|
|
"result": "Imported",
|
|
}
|
|
|
|
except Exception:
|
|
|
|
conn.rollback()
|
|
|
|
raise |