Skip to content

jaalso/security-scripts

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

13 Commits
 
 
 
 
 
 

Repository files navigation

🐍 Python Security Scripts

Security-focused Python scripts for offensive and defensive operations ·


📁 Tools

# Tool Description Status
01 log-enricher Enrich CSV auth logs with IP geolocation · flag non-Swiss access ✅ Complete
02 sbom-scanner Scan SBOM JSON files against OSV.dev · CI/CD-friendly exit codes ✅ Complete
03 dns-typosquatting Detect typosquatted domains in DNS logs via Levenshtein edit distance ✅ Complete
04 soc-triage-helpers Fast terminal utilities for IR — file hashing · DNS resolution ✅ Complete
05 xor-decrypt Decrypt repeating-key XOR ciphertext · bitwise crypto from first principles ✅ Complete
06 certinfo.py TLS certificate subject · issuer · expiry extraction 🔜 Planned
07 urlrep.py URL reputation via URLscan.io + VirusTotal API 🔜 Planned

01 · log-enricher

Tech stack: Python 3.13 · requests · ip-api.com REST API · csv · sys · time
Context: SCI - Module 7 Scripting & Automation (Class 2511)

A Python tool that enriches CSV log files with IP geolocation data, flagging non-Swiss access for security review.

The Problem:
Raw security logs contain IP addresses, but an analyst can't tell from 185.137.62.10 alone whether that's normal business traffic or suspicious access from a high-risk jurisdiction. Manually looking up each IP via WHOIS or a web service is slow and unscalable. For a pharmaceutical company with thousands of daily authentication events, this is unworkable.

The Solution:
Read a CSV of authentication events → call ip-api.com for each unique IP → cache results → respect rate limits → print timestamped alerts for any non-Swiss IP. Output is designed to be piped directly into SIEM ingestion or an analyst's triage queue.

Example Output:

python log_enricher.py logfile.csv
[2025-09-29T08:00:12] - IP: 185.6.233.4 (Luxembourg, Luxembourg) - User: alice - Event: login_success
[2025-09-29T08:01:05] - IP: 188.64.128.68 (Russia, Novgorod Oblast) - User: bob - Event: login_failure
[2025-09-29T08:05:37] - IP: 185.46.212.34 (Netherlands, North Holland) - User: henry - Event: download
[2025-09-29T08:09:27] - IP: 185.137.62.10 (Iran, Kermanshah Province) - User: maria - Event: download
[2025-09-29T08:11:45] - IP: 185.220.101.6 (Germany, Brandenburg) - User: paul - Event: download
... (14 alerts total · 15 Swiss IPs filtered silently)

The Iran-Kermanshah download alert is the kind of signal that triggers immediate investigation in a real engagement.

Features:

Feature Implementation
File error handling Three distinct exception types — FileNotFoundError · PermissionError · OSError
API error handling Catches requests.exceptions.RequestException — timeouts · DNS failures · connection errors
Request timeout 5-second per-request timeout — prevents indefinite hangs
Result caching In-memory dict keyed by IP — repeated IPs looked up only once
Rate limiting 1.35-second sleep between API calls (60sec / 45req) — stays within ip-api.com free tier
CLI usability Validates argument count · prints usage on missing input · sys.exit(1) for proper error codes

Installation:

git clone https://github.com/jaalso/security-scripts
cd security-scripts/log-enricher
python3 -m venv .venv
source .venv/bin/activate        # Windows: .venv\Scripts\activate
pip install requests
python log_enricher.py logfile.csv

Source:

import sys
import csv
import time
import requests

# Check if a filepath argument was provided
if len(sys.argv) < 2:
    print("Usage: python log_enricher.py <csv_file>")
    sys.exit(1)

filepath = sys.argv[1]

# Open and parse the CSV file
try:
    with open(filepath, "r") as f:
        reader = csv.DictReader(f)
        rows = list(reader)
except FileNotFoundError:
    print(f"Error: file '{filepath}' not found.")
    sys.exit(1)
except PermissionError:
    print(f"Error: permission denied reading '{filepath}'")
    sys.exit(1)
except OSError as e:
    print(f"Error reading '{filepath}': {e}")
    sys.exit(1)

# Cache for IP lookups - avoid duplicate API calls
ip_cache = {}

# Loop through each row in the CSV
for row in rows:
    ip = row["src_ip"]

    # Check if we've already looked up this IP
    if ip in ip_cache:
        data = ip_cache[ip]
    else:
        # Cache miss - call ip-api.com for this IP
        url = f"http://ip-api.com/json/{ip}"
        try:
            response = requests.get(url, timeout=5)
            data = response.json()
        except requests.exceptions.RequestException as e:
            print(f"API error for {ip}: {e}")
            continue
        ip_cache[ip] = data
        time.sleep(1.35)

    # Extract country and region from the lookup result
    country = data.get("country", "Unknown")
    region = data.get("regionName", "Unknown")

    # Flag non-Swiss IPs
    if country != "Switzerland":
        print(
            f"[{row['timestamp']}] - IP: {ip} "
            f"({country}, {region}) - "
            f"User: {row['username']} - "
            f"Event: {row['event']}"
        )

Design Decisions:

csv.DictReader over csv.reader
Returns each row as a dictionary keyed by column name — row["src_ip"] instead of row[1]. Survives column reordering in source data and self-documents what the code is doing.

In-memory dict cache
Sufficient for log files up to millions of rows. For larger workloads, cache would migrate to Redis or SQLite. Cache key is the IP string; value is the full parsed JSON — enabling future filters by ISP or ASN without re-calling the API.

.get() over bracket access for API response fields
data.get("country", "Unknown") handles ip-api failure responses gracefully. Bracket access would raise KeyError and crash mid-batch.

Hardcoded filter (Switzerland) over config file
The brief specified a fixed Swiss baseline. A production version would accept a config file with an allowlist of approved countries, ASN allowlist for cloud providers, and deny list for high-risk jurisdictions.

Limitations & Future Work:

  • HTTP-only API — ip-api.com free tier limitation. HTTPS requires paid tier for production use
  • No retry logic — failed lookups logged but not retried. Production would use exponential backoff with jitter
  • In-memory cache only — lost on script restart. SQLite or Redis for persistent batch jobs
  • Single-threadedaiohttp async approach would reduce wall-clock runtime for very large files
  • Hardcoded filter — production version would accept allowlists, deny lists, and impossible-travel detection

What This Demonstrates:

The read → enrich → filter → alert pattern is the foundation of nearly every detection-engineering pipeline in production — from Wazuh rule enrichment to bespoke SOAR playbooks.

This script was written without IDE autocomplete on the logic, without copy-pasting from online sources, and without AI-generated structural decisions. Every line was typed deliberately, with a pseudocode plan first and a function-by-function build afterwards.


Each section is designed to slot into the existing README.md after the LogEnricher section, matching its visual rhythm but lighter on prose.

The tools table at the top of the existing README should also be updated — see the "Updated tools table" section at the end.


02 · sbom-scanner

#02--sbom-scanner

Tech stack: Python 3.13 · requests · OSV.dev REST API · json · sys Context: SCI - Module 7 Scripting & Automation (Class 2511)

A Python tool that scans Software Bill of Materials (SBOM) JSON files against Google's OSV.dev vulnerability database, identifies known CVEs in each dependency, and exits with a CI/CD-friendly code for automated build gating.

The Problem: Modern applications depend on dozens or hundreds of open-source libraries. Each one is third-party code, executed with the same privileges as your application — every dependency is a potential attack surface. Real incidents like Log4Shell (2021), the event-stream npm hijack (2018), SolarWinds (2020), and the xz-utils backdoor (2024) all exploited this exact attack vector. Manually tracking which dependencies have known CVEs is impossible at scale.

The Solution: Read the SBOM JSON file → for each component, POST a query to OSV.dev with name + version + ecosystem → parse the response → report any vulnerable components with their CVE identifiers and summaries → exit 0 if clean, exit 1 if vulnerabilities found. The exit code is the contract that lets CI/CD pipelines (GitHub Actions, GitLab CI, Jenkins) automatically block deployments containing known-vulnerable dependencies.

Example Output:

python sbom_scanner.py sbom.json
[OK] uvicorn 0.23.2
[OK] pydantic 2.5.0
[VULNERABLE] requests 2.31.0
    - GHSA-9hjg-9r4m-mvj7: Requests vulnerable to .netrc credentials leak via malicious URLs
    - GHSA-9wx4-h78v-vm56: Requests `Session` object does not verify requests after making first request with verify=False
    - GHSA-gc5v-m9x4-r6x2: Requests has Insecure Temp File Reuse in its extract_zipped_paths() utility function

Vulnerabilities found - review the report above.
echo $?
1

A wrapper shell script can read $? and block deployment when vulnerabilities are detected.

Features:

Feature Implementation
File error handling Four distinct exception types — FileNotFoundError · PermissionError · json.JSONDecodeError · OSError
API error handling Catches requests.exceptions.RequestException — continues scan on per-component failures
Request timeout 10-second per-request timeout — prevents hangs on slow API responses
CI/CD integration Exit code 0 = clean · Exit code 1 = vulnerabilities found or error
Vulnerability detail Reports CVE/GHSA identifier and human-readable summary for each finding

Installation:

git clone https://github.com/jaalso/security-scripts
cd security-scripts/sbom-scanner
python3 -m venv .venv
source .venv/bin/activate        # Windows: .venv\Scripts\activate
pip install requests
python sbom_scanner.py sbom.json

SBOM Format Expected:

{
  "components": [
    {"name": "uvicorn",  "version": "0.23.2"},
    {"name": "pydantic", "version": "2.5.0"},
    {"name": "requests", "version": "2.31.0"}
  ]
}

Source:

import sys
import json
import requests

# Check that exactly 1 argument was provided
if len(sys.argv) != 2:
    print("Usage: python sbom_scanner.py <sbom_file>")
    sys.exit(1)

sbom_file = sys.argv[1]

# Try to open and parse the SBOM file as JSON
try:
    with open(sbom_file, "r") as f:
        sbom_data = json.load(f)
except FileNotFoundError:
    print(f"Error: SBOM file not found: {sbom_file}")
    sys.exit(1)
except PermissionError:
    print(f"Error: Permission denied reading: {sbom_file}")
    sys.exit(1)
except json.JSONDecodeError as e:
    print(f"Error: Invalid JSON in {sbom_file}: {e}")
    sys.exit(1)
except OSError as e:
    print(f"Error reading '{sbom_file}': {e}")
    sys.exit(1)

components = sbom_data["components"]

vulnerabilities_found = False
OSV_API = "https://api.osv.dev/v1/query"

# Loop through each component and check it against OSV.dev
for component in components:
    name = component["name"]
    version = component["version"]

    # Build the JSON payload OSV.dev expects
    payload = {
        "package": {"name": name, "ecosystem": "PyPI"},
        "version": version
    }

    # Send POST request with the JSON body
    try:
        response = requests.post(OSV_API, json=payload, timeout=10)
        data = response.json()
    except requests.exceptions.RequestException as e:
        print(f"API error checking {name} {version}: {e}")
        continue

    # Check the response — does it contain any vulnerabilities?
    if "vulns" in data and data["vulns"]:
        vulnerabilities_found = True
        print(f"[VULNERABLE] {name} {version}")
        for vuln in data["vulns"]:
            vuln_id = vuln.get("id", "unknown")
            summary = vuln.get("summary", "no summary")
            print(f"    - {vuln_id}: {summary}")
    else:
        print(f"[OK] {name} {version}")

# Final summary based on the flag — exit code is the CI/CD contract
print()
if vulnerabilities_found:
    print("Vulnerabilities found - review the report above.")
    sys.exit(1)
else:
    print("No known vulnerabilities found.")
    sys.exit(0)

Design Decisions:

POST with json=payload OSV.dev expects a JSON body, not URL parameters. The requests.post(url, json=payload) argument auto-serializes the dict, sets Content-Type: application/json, and sends the body. This is the dominant pattern for modern REST APIs (GitHub, AWS, Stripe, OpenAI all use this shape).

Boolean flag pattern over early exit The script continues scanning all components even after finding a vulnerability, then decides the exit code at the end. An analyst gets the complete picture, not just the first failure. Same pattern used by SIEM rule evaluation, malware scanning, and compliance check tools.

Hardcoded "PyPI" ecosystem OSV.dev tracks vulnerabilities across npm, Maven, Go, RubyGems, NuGet, crates.io, and PyPI. For a multi-language project, this would be per-component. The brief assumed a Python FastAPI app, so PyPI is hardcoded.

Limitations & Future Work:

  • Single ecosystem (PyPI hardcoded) — production version would detect or carry the ecosystem per-component
  • No severity filtering — currently LOW severity issues block builds the same as CRITICAL. Add --min-severity flag
  • One request per component — OSV.dev supports /v1/querybatch for up to 1,000 components in one call (massive speedup for large SBOMs)
  • Plain text output only — JSON or SARIF output would enable native GitHub Code Scanning integration
  • No allowlist — risk-accepted CVEs cannot currently be suppressed

What This Demonstrates:

The SBOM → vuln-DB → block-or-pass pipeline is the foundation of modern supply-chain security. Production equivalents (Dependabot, Snyk, Grype, Trivy, Dependency-Track) all implement this same pattern with enterprise polish — UI, persistence, multi-language support, severity scoring, exception handling. The from-scratch version proves understanding of the underlying mechanics: API integration, JSON parsing, boolean state tracking across a loop, and exit-code-based pipeline integration.

This script was written chunk-by-chunk with pseudocode-first planning, every line typed deliberately, and tested at each stage. No IDE autocomplete on logic, no copy-paste from online sources.


03 · dns-typosquatting

#03--dns-typosquatting

Tech stack: Python 3.13 · python-Levenshtein · sys Context: SCI - Module 7 Scripting & Automation (Class 2511)

A Python tool that hunts for typosquatted domains in DNS query logs by comparing every unique queried domain against a legitimate target using Levenshtein edit distance. Reports likely impersonation attempts beyond an analyst-configurable similarity threshold.

The Problem: Typosquatting is one of the most common phishing setups: attackers register domains that look almost identical to a legitimate brand — n0vartis.ch (zero instead of letter O), novartiz.ch (substituted character), or novartis.com (TLD swap on a .ch legitimate domain). A SOC analyst monitoring DNS query logs needs to detect these in their company's traffic before users click a phishing link or attacker infrastructure is fully operational. Manually scanning logs for visually similar domains doesn't scale.

The Solution: Read a DNS query log → extract every unique queried domain (deduplicated via a set) → prompt the analyst for a similarity threshold → compute Levenshtein edit distance between each observed domain and the target → flag and sort by closest match. The output lists every suspicious domain with its distance, ready for follow-up investigation (WHOIS, certificate transparency, traffic analysis).

Example Output:

python detect_typosquatting.py querylog.log novartis.ch
Define the similarity threshold (e.g., 2 for close matches): 2
Typosquatted domains similar to novartis.ch (threshold=2):
  - n0vartis.ch (distance: 1)
  - novartiz.ch (distance: 1)
  - novartis.com (distance: 2)

Each result represents a different canonical typosquat pattern: n0vartis.ch is a homoglyph attack (letter O replaced with digit 0), novartiz.ch is a substitution attack (s replaced with z), and novartis.com is a TLD swap. The Levenshtein algorithm catches all three with one comparison.

Features:

Feature Implementation
File error handling Three distinct exception types — FileNotFoundError · PermissionError · OSError
Input validation Catches ValueError on non-integer threshold input with friendly error message
Deduplication set() automatically removes duplicate domains — O(1) membership checks
Edit distance python-Levenshtein C implementation — microseconds per comparison
Sorted output Closest matches (lowest distance) appear first — most-likely typosquats at top

Installation:

git clone https://github.com/jaalso/security-scripts
cd security-scripts/dns-typosquatting
python3 -m venv .venv
source .venv/bin/activate        # Windows: .venv\Scripts\activate
pip install python-Levenshtein
python detect_typosquatting.py querylog.log novartis.ch

Log Format Expected:

2025-09-29T08:00:00 client=10.0.0.2 query=google.com A
2025-09-29T08:00:01 client=10.0.0.3 query=microsoft.com A

The script extracts the value after query= from each line and ignores all other fields.

Source:

import sys
import Levenshtein

# Check that exactly 2 arguments were provided
if len(sys.argv) != 3:
    print("Usage: python detect_typosquatting.py <log_file> <target_domain>")
    sys.exit(1)

log_file = sys.argv[1]
target_domain = sys.argv[2]

# Try to open and read the log file
try:
    with open(log_file, "r") as f:
        log_lines = f.readlines()
except FileNotFoundError:
    print(f"Error: Log file not found: {log_file}")
    sys.exit(1)
except PermissionError:
    print(f"Error: Permission denied reading: {log_file}")
    sys.exit(1)
except OSError as e:
    print(f"Error reading '{log_file}': {e}")
    sys.exit(1)

# Extract all unique domains from the log lines
unique_domains = set()
for line in log_lines:
    # Each line: timestamp client=IP query=DOMAIN A
    parts = line.split()
    for part in parts:
        if part.startswith("query="):
            domain = part[len("query="):]
            unique_domains.add(domain)
            break

# Ask the analyst for the similarity threshold
try:
    threshold_input = input("Define the similarity threshold (e.g., 2 for close matches): ")
    threshold = int(threshold_input)
except ValueError:
    print(f"Error: '{threshold_input}' is not a valid integer.")
    sys.exit(1)

# Compare each unique domain to the target using Levenshtein distance
flagged = []
for domain in unique_domains:
    distance = Levenshtein.distance(domain, target_domain)
    if 0 < distance <= threshold:
        flagged.append((domain, distance))

# Sort flagged results by distance ascending (closest matches first)
flagged.sort(key=lambda x: x[1])

# Print results in the format the brief specified
if flagged:
    print(f"Typosquatted domains similar to {target_domain} (threshold={threshold}):")
    for domain, distance in flagged:
        print(f"  - {domain} (distance: {distance})")
else:
    print(f"No typosquatted domains detected (threshold={threshold}).")

Design Decisions:

set() over list for unique domains Sets give O(1) deduplication for free. With a list, we would need explicit if domain not in domains: checks before every append. For 99 log lines this is cosmetic; for million-line production logs, sets are essential.

Chained comparison 0 < distance <= threshold Reads like math, evaluates as distance > 0 and distance <= threshold. The > 0 excludes distance 0 — meaning the legitimate domain queried by your own users won't flag itself as a typosquat of itself.

Interactive threshold prompt over CLI flag Marco's brief specified an analyst-input prompt. For automation, a --threshold N flag would be cleaner — a production version would support both (flag if provided, prompt if not).

Limitations & Future Work:

  • Levenshtein measures characters, not visual similarityrn vs m looks identical in many fonts but has distance 2
  • Single target per run — comparing against multiple legitimate domains would need a loop wrapper
  • No severity tagging — homoglyph attacks, TLD swaps, and typos all get equal weight despite different threat profiles
  • No WHOIS / certificate enrichment — flagged domains lack registration date and SSL cert data
  • Loads entire log into memory — fine for KB/MB logs, would need streaming for GB-scale logs

What This Demonstrates:

Production equivalents (dnstwist, URLCrazy, Spamhaus DBL, certstream-based monitors) all implement variations of this same pattern: take a legitimate brand, generate or detect lookalikes, alert on matches. The from-scratch version demonstrates string parsing, set theory, edit-distance algorithms, and tuple manipulation with lambda-based sorting.

The three results from the test data — homoglyph (o0), substitution (sz), and TLD swap (.ch.com) — represent the three canonical attacker patterns. Recent registration of a Levenshtein-1 typosquat in DNS queries is one of the strongest indicators of an active phishing campaign targeting an organization.


04 · soc-triage-helpers

#04--soc-triage-helpers

Tech stack: Python 3.13 · hashlib · socket · sys Context: SCI - Module 7 Scripting & Automation (Class 2511)

Two small standalone scripts an analyst can call from the terminal during incident response — fast, dependency-free, and built on the Python standard library. Not full applications but the kind of utility a SOC analyst writes once and uses for years.

The Problem: During incident response, an analyst constantly needs to answer two questions: "What is the cryptographic hash of this file?" (for sample triage, IOC matching, integrity checks) and "What IPs does this domain currently resolve to?" (for C2 attribution, DNS history, geolocation). GUI tools and web services exist for both, but breaking from the terminal mid-investigation slows down the workflow. Two one-shot CLI scripts on the analyst's path solve this.

04a · hashfile.py

The Solution: Take any file path as an argument, read it in binary mode, and print the SHA-256, SHA-1, and MD5 digests on a single line each. All three algorithms because each fits a different IOC ecosystem: SHA-256 for modern malware feeds (VirusTotal, Hybrid Analysis), SHA-1 for older corporate baselines, MD5 for legacy threat-intel platforms that haven't migrated.

Example Output:

python hashfile.py suspicious_attachment.docx
File: suspicious_attachment.docx
SHA-256: a0dee963b7ec0f3561f295b16bf17a2294496347e7fd2047b66803b8a4bbad15
SHA-1:   3f1c0e29b87a4ad7c5e2f8b0d6e4a91e8c2b7d35
MD5:     7d4a3c8e1f5b2a9d6e3c7f0a8b5d2e9f

Source:

import sys
import hashlib

# Check that exactly 1 argument was provided
if len(sys.argv) != 2:
    print("Usage: python hashfile.py <file>")
    sys.exit(1)

filepath = sys.argv[1]

# Try to open and read the file in binary mode
try:
    with open(filepath, "rb") as f:
        data = f.read()
except FileNotFoundError:
    print(f"Error: File not found: {filepath}")
    sys.exit(1)
except PermissionError:
    print(f"Error: Permission denied reading: {filepath}")
    sys.exit(1)
except OSError as e:
    print(f"Error reading '{filepath}': {e}")
    sys.exit(1)

# Compute all three hash digests
sha256 = hashlib.sha256(data).hexdigest()
sha1 = hashlib.sha1(data).hexdigest()
md5 = hashlib.md5(data).hexdigest()

# Print in a format easy to copy-paste into IOC platforms
print(f"File: {filepath}")
print(f"SHA-256: {sha256}")
print(f"SHA-1:   {sha1}")
print(f"MD5:     {md5}")

04b · dnslookup.py

The Solution: Take a domain as an argument and resolve it via the OS resolver — return the primary hostname, any aliases, and all IPs the domain currently resolves to. Uses Python's built-in socket module, no external dependencies. Sufficient for quick reconnaissance during IR; for ground-truth DNS protocol-level queries an analyst would reach for dig instead.

Example Output:

python dnslookup.py google.com
Domain: google.com
IPs:
  - 142.250.74.110
python dnslookup.py www.swiss-cyber-institute.com
Domain: www.swiss-cyber-institute.com
Aliases:
  - swiss-cyber-institute.com
IPs:
  - 185.158.133.1

Source:

import sys
import socket

# Check that exactly 1 argument was provided
if len(sys.argv) != 2:
    print("Usage: python dnslookup.py <domain>")
    sys.exit(1)

domain = sys.argv[1]

# Resolve the domain — gethostbyname_ex returns (hostname, aliases, ip_list)
try:
    hostname, aliases, ip_list = socket.gethostbyname_ex(domain)
except socket.gaierror as e:
    print(f"Error: Could not resolve '{domain}': {e}")
    sys.exit(1)

# Print primary hostname
print(f"Domain: {hostname}")

# Print aliases only if any were returned
if aliases:
    print("Aliases:")
    for alias in aliases:
        print(f"  - {alias}")

# Print all resolved IPs
print("IPs:")
for ip in ip_list:
    print(f"  - {ip}")

Design Decisions:

hashlib over subprocess to sha256sum Pure Python — same script runs on Linux, macOS, and Windows without depending on a shell utility. Same hash output as the system tools (verified byte-by-byte against sha256sum on Kali).

gethostbyname_ex() over gethostbyname() The _ex variant returns aliases and the full IP list as a tuple, not just the first IP. Real domains often have multiple A records and CNAME chains — analysts need all of them.

Tuple unpacking in one line hostname, aliases, ip_list = socket.gethostbyname_ex(domain) — Pythonic, reads naturally, no intermediate variable needed.

Limitations & Future Work:

  • dnslookup uses the OS resolver — subject to /etc/hosts, DNS cache, and corporate split-DNS. For ground-truth queries, dig @8.8.8.8 domain.com or the dnspython library bypass these
  • hashfile reads the entire file into memory — for very large files (GB), streaming via hashlib.update() chunks would be needed
  • No IOC platform integration — output is formatted for human copy-paste, not API consumption
  • No reverse-DNS lookupdnslookup only does forward queries

What This Demonstrates:

These two scripts are the kind of utility a working SOC analyst writes once and uses for years. Small, focused, dependency-free, terminal-callable. They demonstrate fluency with Python's standard library (hashlib, socket) and the same disciplined error handling as the larger scripts in this repository. Together they form the building blocks an analyst would compose into longer investigative pipelines.


05 · xor-decrypt

#05--xor-decrypt

Tech stack: Python 3.13 · os · sys (standard library only) Context: SCI - Module 7 Scripting & Automation (Class 2511)

A Python tool that decrypts files encrypted with repeating-key XOR using bytewise operations and modulo key cycling. Built without any cryptography library — pure first-principles implementation of the bitwise XOR cipher.

The Problem: XOR is the fundamental building block of nearly every modern stream cipher, every CTF challenge involving "the data looks scrambled," and a huge fraction of malware string obfuscation. When analysts encounter XOR-encrypted artifacts (firmware blobs, malware config sections, CTF flags), they need to understand what's actually happening at the byte level — not just call a library function. Tools that hide the math hide the understanding.

The Solution: Read the encrypted file as raw bytes → read the key file as raw bytes → for each byte of the ciphertext, XOR it with the corresponding key byte (using modulo to cycle the key over a longer ciphertext) → write the result with a _decrypted suffix added to the filename. Validate file existence with os.path.exists() before opening. Verify correctness by inspecting the recovered file's magic bytes (PNG signature 89 50 4e 47 0d 0a 1a 0a proves byte-perfect recovery).

Example Output:

python xor_decrypt.py swiss-cyber-institute_encrypted.png swiss-cyber-institute.key
Decrypted: swiss-cyber-institute_encrypted_decrypted.png

Verification via magic-byte inspection:

od -An -tx1 -N8 swiss-cyber-institute_encrypted_decrypted.png
 89 50 4e 47 0d 0a 1a 0a

The recovered first 8 bytes match the PNG signature defined in RFC 2083 exactly. Cryptographic proof that all 18,313 bytes decrypted correctly — not just visual inspection.

Features:

Feature Implementation
File error handling LBYL pattern via os.path.exists() — distinct messages for missing encrypted vs. key files
Binary I/O "rb" and "wb" modes — no text encoding interference with byte data
Key cycling Modulo operator (i % len(key)) — works for any key length, any file size
Self-inverse Same script encrypts and decrypts (XOR property) — only the input determines direction
Output naming os.path.splitext() to insert _decrypted before the file extension

Installation:

git clone https://github.com/jaalso/security-scripts
cd security-scripts/xor-decrypt
python xor_decrypt.py encrypted_file key_file

No external dependencies. Standard library only.

Source:

import sys
import os

# Check that exactly 2 arguments were provided (encrypted file + key file)
if len(sys.argv) != 3:
    print("Usage: python xor_decrypt.py <encrypted_file> <key_file>")
    sys.exit(1)

encrypted_file = sys.argv[1]
key_file = sys.argv[2]

# Validate that both files exist — distinct messages for each
if not os.path.exists(encrypted_file):
    print(f"Error: Encrypted file not found: {encrypted_file}")
    sys.exit(1)
if not os.path.exists(key_file):
    print(f"Error: Key file not found: {key_file}")
    sys.exit(1)

# Read both files in binary mode
with open(encrypted_file, "rb") as f:
    encrypted_data = f.read()
with open(key_file, "rb") as f:
    key = f.read()

# XOR-decrypt: each byte ^ corresponding key byte (cycling the key via modulo)
decrypted_data = bytearray()
for i in range(len(encrypted_data)):
    key_byte = key[i % len(key)]
    decrypted_byte = encrypted_data[i] ^ key_byte
    decrypted_data.append(decrypted_byte)

# Build output filename: insert "_decrypted" before the extension
name, ext = os.path.splitext(encrypted_file)
output_file = name + "_decrypted" + ext

# Write the decrypted bytes to the output file (binary mode)
with open(output_file, "wb") as f:
    f.write(decrypted_data)

# Print the output path to confirm success
print(f"Decrypted: {output_file}")

Design Decisions:

LBYL (os.path.exists()) over EAFP (try/except) The brief required distinct error messages per missing file. With a single try/except block, identifying which file failed would require parsing the exception message — awkward and brittle. Checking each file independently before opening gives clean, specific error reporting at the cost of a microscopic race-condition window (not a concern for single-user homework).

bytearray over bytes for the output bytes is immutable; bytearray is mutable. Since we're building the output byte-by-byte with .append() calls, the mutable variant is the right tool. The final bytearray is then written directly to the output file without conversion.

Modulo key cycling over key padding key[i % len(key)] lets us handle any key length against any ciphertext length with one line. The alternative — padding the key to match the ciphertext length up front — would waste memory for large files with short keys.

Limitations & Future Work:

  • Cipher is cryptographically weak — repeating-key XOR with a short key is trivially breakable if any plaintext can be guessed. Real security applications must use modern AEAD ciphers (ChaCha20-Poly1305, AES-GCM) — this script is for understanding the underlying primitive, not for actual security
  • No key validation — empty key file would crash with ZeroDivisionError on i % len(key)
  • Byte-at-a-time loop is slow for large files — for multi-MB ciphertexts, a vectorized approach using bytes(a ^ b for a, b in zip(data, cycled_key)) would be much faster
  • Decrypt-only — could be extended to a --mode encrypt|decrypt flag (the math is identical; only the output suffix differs)
  • No cryptanalysis — does not attempt to recover the key from ciphertext alone. The classic exercise is single-byte XOR brute-forcing using English-text frequency scoring

What This Demonstrates:

The XOR operation in this script is mathematically identical to what appears inside AES (in CTR and GCM modes), ChaCha20, every stream cipher, every one-time pad, and the inner workings of HMAC. Understanding it at the byte level is foundational — analysts who only know XOR as "cryptography.fernet" miss the picture entirely.

The verification methodology — checking that the first 8 bytes of the recovered file match a known file signature (PNG magic) — is the same technique used in malware analysis when identifying XOR-obfuscated payloads. If the first bytes after XOR look like 4D 5A (MZ header), the decrypted blob is a Windows executable. If they look like 7F 45 4C 46, it's a Linux ELF. File-format inference via magic bytes is a daily-use SOC skill.

This script was built chunk-by-chunk with pseudocode-first planning. The repeating-key XOR's self-inverse property — that the same operation encrypts and decrypts depending only on the input — was verified empirically with a round-trip test using both od for magic-byte inspection and the companion hashfile.py tool for cryptographic-hash comparison.


🧰 Tools Used

Category Tools
Language Python 3.13
Libraries requests · json · hashlib · socket · csv · sys · time · os · Levenshtein
APIs ip-api.com REST API · OSV.dev REST API
Platform Kali Linux · Windows 10 · VS Code

⚖️ Legal & Ethical Notice

All scripts documented in this repository were developed for educational purposes as part of the Swiss Cyber Institute Cybersecurity Specialist program. Scripts are intended for use in authorised environments only. All work complies with Swiss law and ethical hacking standards.


📄 License

MIT — see LICENSE

About

Python scripts for offensive and defensive security operations

Resources

License

Stars

0 stars

Watchers

0 watching

Forks

Releases

No releases published

Packages

 
 
 

Contributors