scipio avatar

Learn Ethical Hacking (#78) - Secure Development - Writing Code That Doesn't Get Hacked

scipio

Published: 06 Jul 2026 › Updated: 06 Jul 2026Learn Ethical Hacking (#78) - Secure Development - Writing Code That Doesn't Get Hacked

Learn Ethical Hacking (#78) - Secure Development - Writing Code That Doesn't Get Hacked

Learn Ethical Hacking (#78) - Secure Development - Writing Code That Doesn't Get Hacked

leh-banner.jpg

What will I learn

  • The developer's role in security -- why most vulnerabilities are created during development, not operations;
  • Input validation -- the single most important secure coding practice and how to implement it correctly;
  • Output encoding -- preventing XSS by encoding data for its output context;
  • Parameterized queries -- eliminating SQL injection forever with one pattern;
  • Authentication and session management -- secure implementations using proven libraries;
  • Secure API design -- rate limiting, authorization checks, and security headers;
  • Dependency management -- keeping third-party code from becoming your vulnerability;
  • Security code review -- the checklist that catches 90% of common vulnerabilities before they ship;
  • Defense: OWASP Secure Coding Practices, static analysis tools, and building a security-aware development culture.

Requirements

  • A working modern computer running macOS, Windows or Ubuntu;
  • Proficiency in at least one programming language (Python, JavaScript, or Java);
  • Understanding of web vulnerabilities from Episodes 11-28;
  • Understanding of supply chain attacks from Episode 45;
  • The ambition to learn ethical hacking and security research.

Difficulty

  • Intermediate

Curriculum (of the Learn Ethical Hacking Series):

Learn Ethical Hacking (#78) - Secure Development - Writing Code That Doesn't Get Hacked

Solutions to Episode 77 Exercises

Exercise 1: Static malware analysis (abbreviated).

Sample: Agent Tesla (SHA256: 8a3f... from MalwareBazaar)
Strings: 3 SMTP servers, 2 Telegram tokens, base64 config
Imports: System.Net.Mail, System.Windows.Forms, Microsoft.Win32
VirusTotal: 58/72. High entropy .rsrc (7.4) = packed payload.

The VirusTotal detection ratio of 58 out of 72 engines is actually a useful data point beyond the obvious "yes it is malicious" conclusion. Agent Tesla is commodity malware -- it has been circulating since 2014 and is one of the most widely documented infostealers in existence. The fact that 14 engines STILL missed it tells you something about signature-based detection limits even for well-known families. Those 14 engines likely missed it because this particular variant uses a custom packer (the 7.4 entropy in the .rsrc section confirms the resource section is packed or encrypted) that changes the binary structure enough to evade static signatures while the underlying payload remains the same Agent Tesla code.

The three SMTP server addresses and two Telegram bot tokens extracted from strings are the exfiltration channels -- Agent Tesla harvests keystrokes, clipboard content, screenshots, and stored credentials, then sends them out via email (SMTP) or Telegram bot API. Finding both channels in the same sample is typical of modern infostealers: they use multiple exfiltration paths for redundancy so that if one C2 channel is taken down, the stolen data still reaches the attacker through the other. The System.Net.Mail import confirms SMTP exfiltration capability, System.Windows.Forms indicates keylogging and clipboard monitoring (these APIs are needed for hooking keyboard input and reading clipboard content), and Microsoft.Win32 indicates registry access for persistence -- the exact pattern we discussed in episode 77's malware type classification.

Exercise 2: Dynamic analysis (abbreviated).

Spawned: %AppData%\svchost.exe (persistence copy)
Registry: HKCU\Run\svchost for startup persistence
Network: SMTP to mail.evil-server[.]ru:587 exfiltrating keylogs
DNS: api.telegram.org (backup exfil channel)
Files: %Temp%\log.tmp updated every 30 seconds with keystrokes

The dynamic analysis confirms what static analysis suggested but adds critical behavioral details that were invisible from the binary alone. The persistence copy to %AppData%\svchost.exe is a classic technique: the malware copies itself to a location where the user has write access (no admin required), names itself after a legitimate Windows process (svchost.exe -- there are normally a dozen running on any Windows machine), and then registers a Run key at HKCU\Software\Microsoft\Windows\CurrentVersion\Run so it starts automatically every time the user logs in. This is the exact registry persistence mechanism we hunted for in episode 75. In a real investigation, Regshot's before-after diff would have caught this registry modification instantly, and ProcMon filtered for the malware's PID would have shown the entire sequence: file copy operation, registry write, SMTP connection, DNS query -- all timestamped and attributable to a single process.

The 30-second keylog update cycle to %Temp%\log.tmp is the local staging mechanism. Rather than transmitting every keystroke individually (which would generate conspicuous network traffic), the infostealer buffers keystrokes locally and exfiltrates them in batches. The SMTP connection to port 587 (submission port with STARTTLS) rather than port 25 (unencrypted SMTP) is a deliberate choice -- port 587 traffic is encrypted, making it harder for network monitoring (episode 74) to inspect the exfiltrated content without TLS interception. And the simultaneous DNS query to api.telegram.org confirms the backup exfiltration channel is active, not just a dead string in the binary.

Exercise 3: YARA rules (abbreviated).

Rule 1 (packed PE): entropy > 7.0 in any section -> detected sample
Rule 2 (macro docs): OLE + AutoOpen + Shell/powershell -> detected test.docm
Rule 3 (Agent Tesla): PE + .NET + smtp + telegram + keylog -> detected sample
All 3 rules: zero false positives against clean file corpus.

Zero false positives across all three rules against a clean file corpus is the right outcome, but the real test of these rules happens at scale. The packed PE rule (entropy > 7.0) is deliberately broad -- it catches more than just malware (legitimate software that uses UPX compression will also trigger it), which is fine for a triage rule where the goal is to flag files for closer inspection rather than to definitively classify them. The macro document rule combining OLE format detection with auto-execution strings and PowerShell invocation is more specific and should have a very low false positive rate in practice because legitimate Office macros almost never call PowerShell with encoded commands. The Agent Tesla rule is the most targeted -- it looks for the combination of .NET PE format (Agent Tesla is written in C#), SMTP-related strings, Telegram API strings, and keylogging indicators. That conjunction is specific enough to detect the family without flagging legitimate .NET applications that happen to use SMTP (like email clients).

The important lesson is rule layering: a broad triage rule catches everything suspicious (including false positives), a medium-specificity rule narrows to likely malicious documents, and a family-specific rule identifies the exact threat. Your detection pipeline should use all three layers -- broad rules for initial flagging, specific rules for classification, and family rules for attribution and response prioritization.


Episode 77 covered malware analysis -- the systematic process of understanding what malicious software actually does through static examination and dynamic observation. We looked at sandbox environments, YARA rule writing, IOC extraction, and the difference between knowing a file is malicious and understanding HOW it is malicious. That episode answered the question "what does this weapon do?"

Today we flip the perspective entirely. Instead of analyzing bad code after the fact, we write good code from the start. Every vulnerability you have seen in this series -- every SQL injection, every XSS, every SSRF, every deserialization bug -- was created by a developer. Not by an attacker, not by an operations team, not by a misconfigured firewall. A developer wrote code that trusted user input, and an attacker exploited that trust. The most effective security investment any organization can make is not another tool, not another appliance, not another monitoring dashboard. It is teaching developers to stop creating vulnerabilities in the first place.

Secure Code Is the Only Real Fix

Here we go. Every defense we have covered in this series -- firewalls (episode 73), IDS/IPS, SIEM (episode 74), EDR, hardening (episodes 71-72), network segmentation, threat hunting (episode 75) -- compensates for insecure code. If the application used parameterized queries, you would not need a WAF rule for SQL injection. If templates auto-escaped output, XSS would not exist. If input was validated and internal URLs were blocked, SSRF would fail. If deserialization only accepted whitelisted classes, RCE via deserialization would be impossible.

These are not theoretical arguments. OWASP's Top 10 has listed injection flaws and broken authentication in its top positions for over a decade, and both are entirely preventable by writing code correctly. The fix for SQL injection has been known since the late 1990s (parameterized queries). The fix for XSS has been known since the early 2000s (output encoding). The fix for CSRF has been known since 2008 (synchronizer tokens). And yet these vulnerabilities keep appearing in production code because developers are not taught to avoid them, or they know the theory but skip the practice under deadline pressure.

I argue that if you take one thing from 78 episodes of this series, it should be this: security is not a feature you bolt on after development. It is a property of how the code is written. Everything else -- monitoring, hunting, forensics, incident response -- is damage control for when secure development fails. Having said that, secure development WILL sometimes fail (humans make mistakes, requirements change, new attack techniques emerge), which is why all those other layers exist. Defense in depth means accepting that no single layer is perfect. But making the code layer as strong as possible reduces the load on every other layer.

Input Validation -- The Foundation of Everything

If you remember episode 12 (SQL injection), episode 14 (XSS), episode 18 (SSRF), and episode 20 (file uploads), they all share one root cause: the application trusted user input without validation. A username field that accepts SQL syntax. A comment field that accepts JavaScript. A URL parameter that accepts internal IP addresses. A file upload that accepts executables. Every single one of these bugs would have been prevented by validating input at the boundary where it enters the application.

# NEVER trust user input.
# Validate type, length, format, and range on EVERY input.

# BAD: direct use of user input in a query (SQLi from episode 12)
@app.route('/user/')
def get_user(user_id):
    query = f"SELECT * FROM users WHERE id = {user_id}"  # SQLi!
    cursor.execute(query)

# GOOD: validate THEN use parameterized query
@app.route('/user/')
def get_user(user_id):
    try:
        uid = int(user_id)
        if uid < 1 or uid > 1000000:
            abort(400)
    except ValueError:
        abort(400)
    cursor.execute("SELECT * FROM users WHERE id = %s", (uid,))

The int() conversion is the simplest possible validation: if user_id is not a valid integer, the conversion throws a ValueError and the request is rejected before it ever touches the database. The range check (1 to 1000000) adds a second layer -- even if someone passes a valid integer, it must be within a reasonable range for your application. And the parameterized query (%s placeholder) is the third layer that eliminates SQL injection entirely regardless of what value is passed. Three lines of defensive code, and the entire class of SQL injection attacks we spent two full episodes covering (12 and 13) becomes impossible.

# Common validation patterns for different input types:

import re, os
from urllib.parse import urlparse

def validate_email(email):
    """Reject obviously invalid emails. NOT a complete RFC 5322 check."""
    pattern = re.compile(r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$')
    if not pattern.match(email) or len(email) > 254:
        raise ValueError("Invalid email")
    return email

def validate_filename(filename):
    """Strip path components, reject suspicious characters."""
    clean = os.path.basename(filename)
    if not re.match(r'^[a-zA-Z0-9._-]+$', clean) or clean.startswith('.'):
        raise ValueError("Invalid filename")
    return clean

def validate_url(url):
    """Allow only HTTP(S) with public hostnames -- blocks SSRF."""
    parsed = urlparse(url)
    if parsed.scheme not in ('http', 'https') or not parsed.netloc:
        raise ValueError("Invalid URL")
    import ipaddress
    try:
        ip = ipaddress.ip_address(parsed.hostname)
        if ip.is_private or ip.is_loopback:
            raise ValueError("Internal URLs blocked")
    except ValueError:
        pass  # hostname is not an IP -- DNS will resolve it
    return url

The validate_url function is directly relevant to the SSRF prevention we discussed in episode 18. Remember how SSRF works: the attacker passes a URL like http://169.254.169.254/latest/meta-data/ (the AWS metadata endpoint) and the server fetches it, leaking internal cloud credentials. The validation function above blocks private and loopback IP addresses, which prevents the most obvious SSRF vectors. Having said that, it does NOT prevent DNS rebinding attacks (where a hostname initially resolves to a public IP during validation but resolves to a private IP when the actual request is made) -- for complete SSRF prevention you need to validate the resolved IP at request time, not just the hostname at validation time. The point is that input validation is a spectrum: every check you add eliminates some attack vectors, but no single check eliminates all of them. Defense in depth applies to validation too.

The validate_filename function addresses the file upload vulnerabilities from episode 20. os.path.basename() strips directory traversal components (so ../../../etc/passwd becomes just passwd), the regex allows only alphanumeric characters plus dots, hyphens, and underscores (no null bytes, no semicolons, no special characters that might confuse downstream processing), and the dot-prefix check blocks hidden files on Unix systems (which could be used to overwrite configuration files like .htaccess). Again, this is not a complete defense against malicious uploads -- you also need file type verification, content inspection, and storage outside the web root -- but it eliminates the trivial path traversal and naming attacks.

Parameterized Queries -- Killing SQL Injection Forever

This is the single pattern that eliminates the entire class of SQL injection attacks we covered in episodes 12 and 13. No WAF needed. No input sanitization. No escaping special characters. Just one rule: never put user input into a SQL string. Always use parameters.

# The complete fix for SQL injection. Every language has this.

# BAD: string formatting (vulnerable to every SQLi technique from ep12-13)
query = f"SELECT * FROM users WHERE name = '{username}'"

# GOOD: parameterized query (immune to ALL SQL injection)
cursor.execute("SELECT * FROM users WHERE name = %s", (username,))

# The database driver treats the parameter as DATA, never as SQL syntax.
# Even if username is "'; DROP TABLE users; --", the database sees it
# as a literal string value to match against, not as SQL to execute.

# Same pattern across languages:

# Python (psycopg2): %s placeholder
cursor.execute("SELECT * FROM users WHERE name = %s", (username,))

# Node.js (pg): $1 placeholder
# client.query('SELECT * FROM users WHERE name = $1', [username])

# Java: ? placeholder with PreparedStatement
# ps = conn.prepareStatement("SELECT * FROM users WHERE name = ?");
# ps.setString(1, username);

# Go: $1 placeholder
# db.QueryRow("SELECT * FROM users WHERE name = $1", username)

# ORM (SQLAlchemy): parameterized by default
user = session.query(User).filter(User.name == username).first()

The reason parameterized queries are immune to injection is fundamental to how they work at the protocol level. When you use string formatting, the database receives a single string that contains both the SQL structure AND the user data mixed together -- the database parser cannot distinguish between them. When you use parameters, the database receives the SQL structure and the data as separate entities through the wire protocol. The SQL is compiled into an execution plan first, and then the parameter values are bound to the plan. The parameter values CANNOT change the structure of the query because the structure was already finalized before the values were applied. This is not an escaping trick that clever attackers might bypass -- it is a fundamential architectural separation that makes injection logically impossible ;-)

If you use an ORM like SQLAlchemy, Django ORM, or Sequelize, you get parameterized queries by default for all standard operations. The ORM generates the SQL and binds parameters automatically. The risk with ORMs is when developers bypass the ORM for "complex" queries and fall back to raw SQL with string formatting -- that is where injection sneaks back in. The rule is simple: if you are writing raw SQL, use parameters. If you are using an ORM, stay within its query builder. If you absolutely must use raw SQL through the ORM (some complex queries require it), use the ORM's raw query interface which still supports parameter binding (e.g. session.execute(text("SELECT * FROM users WHERE name = :name"), {"name": username})).

Output Encoding -- Killing XSS

Output encoding is to XSS what parameterized queries are to SQL injection: the complete fix for the entire vulnerability class. If every piece of user data is encoded for its output context before being rendered in HTML, XSS becomes impossible regardless of what the attacker submits.

# XSS happens when user input renders as HTML without encoding.
# Episode 14 covered this in detail from the attacker's perspective.

# BAD: raw string interpolation into HTML
return f"

Hello {username}

"
# XSS if username contains # GOOD: template engine with auto-escaping (Jinja2) return render_template("greeting.html", username=username) # Template:

Hello {{ username }}

# Jinja2 auto-encodes: becomes >, " becomes " # The browser displays the characters literally instead of parsing them as HTML # GOOD: explicit encoding when not using templates from markupsafe import escape return f"

Hello {escape(username)}

"
# Defense in depth: Content-Security-Policy header (episode 15) response.headers['Content-Security-Policy'] = "script-src 'self'" # Even if encoding fails somehow, CSP blocks inline script execution

The critical concept is context-aware encoding. HTML encoding (< to &lt;) works for HTML body content, but it is NOT sufficient for all contexts. If user data appears inside a JavaScript string, you need JavaScript encoding (escaping single quotes and backslashes). If it appears inside a URL parameter, you need URL encoding (percent-encoding special characters). If it appears inside a CSS value, you need CSS encoding. Each context has different dangerous characters and different encoding rules. Modern template engines handle HTML context automatically, but if you are placing user data into JavaScript, URLs, or CSS within your templates, you need to apply the correct encoding for that specific context. The CSP bypass techniques from episode 15 exploited exactly this gap -- the application encoded for HTML but forgot about JavaScript or event handler contexts.

Authentication Done Right

Episode 17 covered authentication bypass from the attacker's perspective. Now here is how to build authentication that resists those attacks:

# Password hashing: bcrypt, scrypt, or Argon2. NEVER MD5, NEVER SHA1.
# Episode 7 explained why: MD5/SHA1 are fast hashes designed for speed.
# Passwords need SLOW hashes designed to resist brute force.

import bcrypt

def hash_password(password):
    return bcrypt.hashpw(password.encode('utf-8'), bcrypt.gensalt(rounds=12))

def verify_password(password, hashed):
    return bcrypt.checkpw(password.encode('utf-8'), hashed)

# The rounds=12 parameter means 2^12 = 4096 iterations of the
# internal hash function. Each password verification takes ~250ms
# on modern hardware -- imperceptible to a user logging in,
# catastrophic to an attacker trying millions of candidates.

# Session security (prevents session hijacking from episode 17):
app.config['SESSION_COOKIE_SECURE'] = True      # HTTPS only
app.config['SESSION_COOKIE_HTTPONLY'] = True     # no JS access
app.config['SESSION_COOKIE_SAMESITE'] = 'Lax'   # CSRF protection
app.config['PERMANENT_SESSION_LIFETIME'] = 3600  # 1 hour timeout

# Multi-factor authentication with TOTP:
import pyotp
totp = pyotp.TOTP(user.totp_secret)
if not totp.verify(user_code, valid_window=1):
    abort(401, "Invalid MFA code")
# valid_window=1 allows the previous and next 30-second code
# to account for clock drift between server and authenticator app

The SESSION_COOKIE_HTTPONLY = True setting is particularly important and often overlooked. Without it, JavaScript running in the browser can read the session cookie via document.cookie -- which means a successful XSS attack (episode 14) can steal the user's session token and send it to the attacker. With HttpOnly set, the cookie is sent with HTTP requests but is invisible to JavaScript. Even if an attacker achieves XSS, they cannot read the session cookie. The SameSite='Lax' setting prevents CSRF attacks (episode 16) by instructing the browser not to send the cookie with cross-origin requests initiated by third-party sites. These are two lines of configuration that neutralize entire attack classes.

The valid_window=1 parameter in the TOTP verification allows a 90-second window (current code plus one before and one after) instead of just the current 30-second code. This is a usability concession -- mobile phone clocks drift, and users get frustrated when their correct code is rejected because of a 5-second discrepancy. The security impact is minimal: an attacker who somehow intercepts a TOTP code has at most 90 seconds to use it, and the code becomes invalid after that regardless.

Secure API Design

Episode 21 covered API security from the attacker's perspective. Every vulnerability we found there has a developer-side fix:

# Rate limiting -- prevents brute force (episode 7) and credential stuffing
from flask_limiter import Limiter
limiter = Limiter(app, default_limits=["100 per hour"])

@app.route('/api/login', methods=['POST'])
@limiter.limit("5 per minute")
def login():
    # With rate limiting, an attacker attempting credential stuffing
    # can try 5 passwords per minute instead of 5000 per second.
    # Combined with account lockout after 10 failures, brute force
    # becomes practically impossible.
    pass

# Authorization on EVERY endpoint -- prevents IDOR (episode 21)
@app.route('/api/user/')
def get_user(user_id):
    if current_user.id != user_id and not current_user.is_admin:
        abort(403)
    # Without this check, user A can access user B's data by
    # changing the ID in the URL -- the #1 API vulnerability
    # from OWASP API Security Top 10 (Broken Object Level Auth)

# Security headers on every response
@app.after_request
def security_headers(response):
    response.headers['X-Content-Type-Options'] = 'nosniff'
    response.headers['X-Frame-Options'] = 'DENY'
    response.headers['Strict-Transport-Security'] = 'max-age=31536000'
    response.headers['Content-Security-Policy'] = "default-src 'self'"
    response.headers['Referrer-Policy'] = 'strict-origin-when-cross-origin'
    return response

# Secure error handling -- never leak internals to the client
@app.errorhandler(500)
def internal_error(error):
    app.logger.error(f"Internal: {error}")     # full detail in server logs
    return {"error": "Internal error"}, 500     # generic message to client
    # Stack traces, database errors, file paths -- NONE of this
    # should ever reach the client. Attackers use error messages
    # for reconnaissance (episode 4) and injection tuning (episode 12).

The IDOR check (Insecure Direct Object Reference) is three lines of code that prevent what is statistically the most common API vulnerability in production applications. Without it, any authenticated user can access any other user's data simply by iterating through IDs in the URL. We covered this in episode 21, and the fix is embarrassingly simple: check whether the authenticated user is authorized to access the requested resource before returning it. The reason this bug is so common is that developers test with their own account (where the authorization check passes by definition) and never test whether user A can access user B's resources.

The security headers in the after_request handler apply defense-in-depth at the HTTP level. X-Content-Type-Options: nosniff prevents the browser from interpreting files as a different MIME type than declared (which prevents certain XSS vectors via uploaded files). X-Frame-Options: DENY prevents the page from being embedded in iframes (which prevents clickjacking attacks). Strict-Transport-Security tells the browser to only use HTTPS for this domain for the next year (which prevents SSL stripping attacks). Each header is one line, and together they close multiple attack surfaces that the application code itself does not handle.

Dependency Management -- Your Weakest Link

Episode 45 covered supply chain attacks in detail -- how attackers poison upstream libraries to compromise downstream applications. From a developer's perspective, every dependency you add to your project is code you did not write, did not review, and are trusting implicitly to not be malicious or vulnerable.

# Pin exact versions -- reproducible builds, no surprise updates
# requirements.txt:
flask==3.0.2
requests==2.31.0
bcrypt==4.1.2

# Scan for known vulnerabilities
pip-audit              # Python (checks against PyPI advisory database)
npm audit              # Node.js (checks against npm advisory database)
cargo audit            # Rust (checks against RustSec advisory database)

# Automate dependency scanning in CI/CD (episode 67)
# GitHub Dependabot, Snyk, or pip-audit in your pipeline
# Fail the build if critical vulnerabilities are found

# The minimal dependency principle:
# Every library you add is attack surface you do not control.
# If you can write it in 20 lines, do not add a library for it.
# The left-pad incident (2016) broke thousands of builds because
# of a single 11-line utility function that people imported
# instead of writing themselves.

The pin exact versions rule is not about preventing upgrades -- it is about preventing UNINTENDED upgrades. If your requirements.txt says requests>=2.28, a fresh install might pull version 2.32 which has a subtle behavioral change that breaks your application, or worse, version 2.31.1 which was published by an attacker who compromised the maintainer's PyPI credentials (this is not hypothetical -- it happens regularly). Pinning to exact versions means your builds are reproducable: the same code, the same dependencies, the same behavior every time. When you want to upgrade, you do it deliberately, review the changelog, and test before deploying.

pip-audit deserves special mention because it does something that manual review cannot: it cross-references your installed packages against the PyPI Advisory Database and the broader OSV (Open Source Vulnerabilities) database to find packages with known CVEs. A project with 50 dependencies (and their transitive dependencies -- which can easily reach 200+ packages) is impossible to audit manually. Running pip-audit in your CI pipeline (as we discussed in episode 67's DevSecOps section) catches known vulnerabilities automatically and fails the build before vulnerable code reaches production.

The Security Code Review Checklist

Every code change that touches user input, authentication, authorization, or data handling should be reviewed against this checklist. Print it, tape it to your monitor, add it to your PR template -- whatever it takes to make it habitual:

For every PR / code review:
[ ] Input validation on all user-supplied data
[ ] Parameterized queries for all database operations
[ ] Output encoding in all HTML/template rendering
[ ] Authentication check on every protected endpoint
[ ] Authorization check (THIS user has access to THIS resource?)
[ ] No hardcoded secrets (API keys, passwords, tokens)
[ ] Error messages reveal no internals (no stack traces, no SQL errors)
[ ] Logging contains no sensitive data (no passwords, tokens, or PII)
[ ] File uploads: type validated, size limited, name sanitized
[ ] Redirects only to whitelisted destinations (open redirect prevention)
[ ] CSRF protection on all state-changing operations
[ ] Rate limiting on authentication endpoints
[ ] Security headers in HTTP responses
[ ] Dependencies checked for known CVEs

This checklist is not comprehensive -- it does not cover business logic flaws (episode 22), deserialization (episode 19), or application-specific vulnerabilities. But it catches the OWASP Top 10 and covers the vulnerability classes that account for roughly 90% of web application security findings in real penetration tests. If every developer checked these 14 items before merging code, the average web application would be dramatically more secure than it is today.

Static Analysis -- Automated Code Review

Manual code review catches logic errors and architectural problems, but it is slow and expensive. Static analysis tools scan your source code automatically and flag potential security issues before the code even runs:

# Semgrep -- open-source, pattern-based static analysis
# Understands Python, JavaScript, Go, Java, Ruby, and more
semgrep --config p/owasp-top-ten .
# Scans for OWASP Top 10 patterns: SQLi, XSS, SSRF, hardcoded secrets

# Bandit -- Python-specific security linter
bandit -r myproject/
# Checks for: eval(), exec(), subprocess with shell=True,
# hardcoded passwords, weak crypto, insecure temp files

# Example Semgrep output:
# myapp/views.py:47 - sql-injection
#   cursor.execute(f"SELECT * FROM users WHERE id = {user_id}")
#   ^^^ user input in SQL string without parameterization
#
# myapp/auth.py:12 - hardcoded-password
#   DB_PASSWORD = "super_secret_123"
#   ^^^ hardcoded credential in source code

# Integrate into CI/CD (episode 67):
# Run semgrep on every pull request
# Block merge if critical findings are not resolved
# This catches vulnerabilities BEFORE they reach production

Semgrep is particularly powerful because it understands code structure (not just text patterns like grep). When Semgrep flags a SQL injection, it has actually traced the data flow from user input to SQL query construction and determined that the user input reaches the query without parameterization. A simple grep for f"SELECT would flag every formatted string containing SQL, including safe uses where the formatted values are constants. Semgrep's pattern matching is context-aware, which dramatically reduces false positives and makes the tool practical for CI/CD integration where false positives waste developer time and erode trust in the tooling.

Bandit for Python catches a different category of issues: dangerous function calls (eval(), exec(), subprocess with shell=True), weak cryptographic algorithms (MD5 for password hashing), insecure temporary file creation (mktemp instead of mkstemp), and hardcoded credentials. Running both Semgrep (for OWASP patterns) and Bandit (for Python-specific issues) gives you broad coverage across security vulnerability categories.

The AI Slop Connection

Episode 6 introduced the problem: AI code generators produce code that looks correct but is systematically insecure. Now, 72 episodes later, you understand exactly what insecure code looks like and why it is dangerous. You can spot a SQL injection in seconds. You can recognize missing output encoding. You can tell when an authentication implementation is vulnerable.

AI code generators consistently produce the patterns we have flagged throughout this episode: SQL queries with string formatting instead of parameters, HTML output without encoding, hardcoded secrets in configuration files, permissive CORS headers that allow any origin, JWT tokens without signature verification, file uploads without type validation, and API endpoints without authorization checks. The AI generates code that WORKS -- it handles the happy path correctly -- but it skips the security considerations that separate production-quality code from vulnerable code.

The fix is not to stop using AI code generators. The fix is to review AI-generated code with the same scrutiny you would apply to a junior developer's pull request. Use the checklist from this episode. Run Semgrep on the output. Check every database query for parameterization. Check every HTML rendering for encoding. Check every API endpoint for authorization. The AI writes fast. You verify carefully. That is the correct division of labor.

And this extends beyond just code generation. As AI systems become integral parts of production applications -- handling user queries, processing input, making decisions -- the security surface they introduce becomes a domain of its own. The patterns we covered today (input validation, output encoding, authorization) apply to AI-integrated systems too, but with additional considerations around prompt injection, model manipulation, and the boundary between what the AI is allowed to do and what it should refuse.

Exercises

Exercise 1: Take a deliberately vulnerable web application (DVWA or Juice Shop) and fix 5 vulnerabilities using the patterns from this episode. For each vulnerability: (a) identify the insecure code, (b) explain why it is vulnerable (reference the relevant episode from this series), (c) write the fixed version using the correct pattern (parameterized query, output encoding, input validation, etc.), (d) verify the fix by attempting the original attack and confirming it no longer works. Document before and after code for all 5 fixes.

Exercise 2: Build a secure REST API from scratch (Flask or Express) with: (a) bcrypt password hashing with cost factor 12, (b) rate-limited login endpoint (5 attempts per minute), (c) JWT authentication with 15-minute token expiry, (d) per-user authorization checks on every resource endpoint (no IDOR), (e) all security headers from the checklist. Run Semgrep with the OWASP Top 10 ruleset and Bandit (if Python) -- aim for zero critical findings. Document your API design and the Semgrep/Bandit results.

Exercise 3: Perform a security code review on a public open-source GitHub project (under 5,000 lines of code -- pick something in your language of choice). Use the checklist from this episode. Document: (a) all findings organized by category (injection, auth, crypto, etc.), (b) severity rating per finding (critical / high / medium / low), (c) recommended fix with specific code changes for the top 3 findings. If you find real vulnerabilities in an actively maintained project, follow responsible disclosure practices (episode 27) and report them to the maintainer before publishing.


De groeten!

scipioHive account@scipio

Leave Learn Ethical Hacking (#78) - Secure Development - Writing Code That Doesn't Get Hacked to:

Written by

Does it matter who's right, or who's left?

Read more #stem posts


Best Posts From scipio

We have not curated any of scipio's posts yet. But you can encourage our curation team to review posts by visiting them regularly and by referring other readers. Because we give priority to frequently read content.

More Posts From scipio