🐍 Python Security Scripts
Security-focused Python scripts for offensive and defensive operations ·
| # | 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 |
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.csvSource:
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-threaded —
aiohttpasync 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.
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-severityflag - One request per component — OSV.dev supports
/v1/querybatchfor 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.
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.chis a homoglyph attack (letter O replaced with digit 0),novartiz.chis a substitution attack (s replaced with z), andnovartis.comis 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 similarity —
rnvsmlooks 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 (o→0), substitution (s→z), 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.
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.
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}")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.comor thednspythonlibrary 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 lookup —
dnslookuponly 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.
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
ZeroDivisionErroroni % 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|decryptflag (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.
| 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 |
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.
MIT — see LICENSE