Python · Hands-On · Workshop · v1.0

Secure
Software
Development
in Python.

A hands-on workshop covering the most common security vulnerabilities in Python — with vulnerable code, secure alternatives, and real-world attack scenarios you can run on your own machine.

// INSTRUCTOR Engr. Zia Ur Rehman
// RELEASED May 20, 2025
// TASKS 9 modules
~/secure-python — zsh
$ python exploit_demo.py # scanning for vulnerabilities... [!] eval() with untrusted input → RCE [!] hardcoded API_KEY in source → leak [!] SQL string concat detected → injection [!] random.random() for tokens → predictable [!] plaintext password storage → critical   $ python apply_secure_patterns.py [✓] ast.parse for safe evaluation [✓] python-decouple for secrets [✓] parameterized queries enabled [✓] bcrypt with salting [✓] all checks passed
// 00 — Overview

Why this matters.

Secure code isn't an afterthought. Every task in this workshop pairs a real-world vulnerability with a concrete, tested fix — the same patterns that prevent breaches in production systems running today.

9
Vulnerability Patterns
18
Code Examples
3
Audit Tools
100%
Hands-On
// 01 — The Tasks

Nine vulnerabilities. Nine fixes.

Each task starts with code that looks reasonable but breaks under pressure. Then the secure alternative — explained, justified, and ready to ship.

01
Code Execution

The danger of eval()

Evaluating arbitrary strings as Python code is the textbook path to remote code execution.

eval() takes a string and runs it as Python. That includes anything a user might type — including __import__('os').system('rm -rf /'). If the input isn't fully trusted, eval is unsafe by default.

Vulnerable

Vulnerable
unsafe_eval.py
user_input = input("Enter a mathematical expression (e.g., 2+2): ")
print(eval(user_input))
# Try entering: __import__('os').system('dir')

Secure alternative

Secure
task1EvalSafe.py
import ast
import operator

def safe_eval(expr):
    allowed_ops = {
        ast.Add: operator.add,
        ast.Sub: operator.sub,
        ast.Mult: operator.mul,
        ast.Div: operator.truediv
    }

    def eval_node(node):
        if isinstance(node, ast.BinOp):
            return allowed_ops[type(node.op)](
                eval_node(node.left),
                eval_node(node.right)
            )
        elif isinstance(node, ast.Num):
            return node.n
        else:
            raise ValueError("Unsupported operation")

    tree = ast.parse(expr, mode='eval')
    return eval_node(tree.body)

expr = input("Enter a simple math expression (e.g., 2+3*4): ")
print("Result:", safe_eval(expr))
Never use eval() when Input comes from untrusted users · in web applications · in public APIs · when working with files, databases, or OS-level tasks.
02
Shell Injection

Subprocess without the shell

Passing user input to os.system() hands the keys to the operating system.

A user enters file.txt; rm -rf / and os.system() happily runs both halves. Subprocess with a list argument bypasses the shell entirely — arguments are passed straight to the kernel, no parsing, no chaining.

Vulnerable

Vulnerable
unsafe_delete.py
import os

filename = input("Enter filename to delete: ")
os.system(f"rm {filename}")
# A user types: file.txt; rm -rf /

Secure alternative

Secure
task2SubprocessSafe.py
import subprocess
from pathlib import Path

filename = input("Enter filename to delete: ")

if Path(filename).exists():
    subprocess.run(["rm", filename])
else:
    print("File does not exist.")
What this prevents Shell injection · command chaining with ; or && · privilege escalation · remote code execution in web servers.
03
Secrets Management

Hardcoded secrets

API keys, tokens, and database passwords don't belong in your source tree.

The moment a secret enters your repo, it's effectively public. GitHub bots actively scan public commits for leaked credentials — and even private repos get cloned, shared, baked into Docker images, and posted in support tickets. Push secrets out of code into a .env file ignored by git.

Vulnerable

Vulnerable
hardcoded.py
API_KEY = "abc123XYZ"
print("Using API Key:", API_KEY)

Secure alternative

Secure
task3SecretsSafe.py
from decouple import config

API_KEY = config('API_KEY')
print("Using API Key:", API_KEY)
Secure
.env (add to .gitignore)
API_KEY=abc123XYZ
Real-world impact Companies have lost millions through leaked credentials picked up by bots scanning public repos within minutes of a push.
04
Supply Chain

Dependency audits

Even safe code can ship insecurely if a third-party library has a known CVE.

A vulnerable version of requests can leak headers. An outdated flask can let an attacker write arbitrary files. Three free tools catch the vast majority of these issues before deployment.

bandit
Static analysis for Python code — flags common vulnerability patterns directly in your source.
safety
Cross-checks installed packages against known CVE databases.
pip-audit
Audits the dependency tree for advisories from the Python Packaging Advisory Database.

Run all three

Audit Commands
terminal
# Install
pip install bandit safety pip-audit

# Run them
bandit -r .
safety check
pip-audit
Pin your versions Use requirements.txt with exact pins (flask==2.2.2) to keep environments reproducible and auditable. Schedule audits as pre-commit hooks or nightly CI jobs.
05
Cryptography

Predictable randomness

random.random() is fine for games. It is not fine for tokens.

Python's random module is deterministic — given the same seed, it produces the same sequence. That makes it useless for anything an attacker might want to guess: session tokens, password reset links, API keys, OTPs. The secrets module uses the operating system's cryptographic source.

Vulnerable

Vulnerable
predictable.py
import random
token = str(random.random())
print("Token:", token)

Secure alternative

Secure
task5RandomnessSafe.py
import secrets

token = secrets.token_urlsafe(16)
print("Secure token:", token)
MethodUse case
secrets.token_bytes(n)Cryptographically secure random bytes
secrets.token_hex(n)Secure hexadecimal tokens for API keys
secrets.token_urlsafe(n)URL-safe base64 tokens for reset links
secrets.choice(seq)Bias-free random selection from sequences
06
Logging Hygiene

Don't log credentials

Logs end up in places you didn't plan for. Treat them as public.

AWS keys in debug logs. Authorization headers in error reports. Reset tokens in stdout that gets piped to a public S3 bucket. Every one of these has happened, repeatedly, at companies you've heard of. The fix is simple: log events, never secrets.

Vulnerable

Vulnerable
login_log.py
username = input("Username: ")
password = input("Password: ")
print(f"User tried to login with {username}:{password}")

Secure alternative

Secure
task6LoggingSafe.py
import logging

logging.basicConfig(level=logging.INFO)

username = input("Username: ")
password = input("Password: ")  # Used but not logged

logging.info(f"Login attempt by user: {username}")
# Never log passwords!
Never log Passwords · auth tokens · Authorization headers · payment details · session cookies · private keys · personally identifiable information.
07
Input Validation

Validate every input

input() always returns a string. Acting on that assumption prevents whole categories of bugs.

Type confusion is a security issue, not just a bug. Unvalidated strings flowing into SQL, file paths, or JSON parsers is how injection happens. Convert and validate at the boundary — every time.

Vulnerable

Vulnerable
type_confusion.py
age = input("Enter your age: ")
print("Next year you will be:", age + 1)
# TypeError: can only concatenate str (not "int") to str

Secure alternative

Secure
task7Inputvalidation.py
try:
    age = int(input("Enter your age: "))
    print("Next year you will be:", age + 1)
except ValueError:
    print("Invalid age input")

Bonus: email validation

Secure
email_validation.py
import re

email = input("Enter your email: ")
pattern = r'^[\w\.-]+@[\w\.-]+\.\w+$'

if re.match(pattern, email):
    print("Valid email")
else:
    print("Invalid email format")
For complex schemas Reach for pydantic (types, ranges, patterns), marshmallow (serialization), or built-in re for emails and phone numbers.
08
Authentication

Hash with bcrypt

Plaintext passwords are a "when," not an "if." Hash with a slow, salted algorithm.

Hashing is one-way. Even with a database leak, attackers can't reverse a bcrypt hash back into a password — and because bcrypt is intentionally slow and salted, brute-force and rainbow tables stop working. MD5 and SHA-1 are not options here. They were designed for speed; password hashing wants the opposite.

Vulnerable

Vulnerable
plaintext.py
users = {"admin": "password123"}
# A database leak immediately exposes every credential.

Secure alternative

Secure
task8PasswordHashing.py
import bcrypt

# Hash the password
password = input("Enter your password: ").encode()
hashed = bcrypt.hashpw(password, bcrypt.gensalt())
print("Stored hash:", hashed)

# Verification (for demo)
if bcrypt.checkpw(password, hashed):
    print("Password match!")
else:
    print("Incorrect password")
Modern alternatives bcrypt is recommended. argon2 is the next-generation option. scrypt is a memory-hard alternative. Avoid MD5 and SHA-1 for passwords entirely.
09
SQL Injection

Parameterize every query

String concatenation in SQL is the oldest vulnerability still on the OWASP Top 10.

A user enters ' OR 1=1 -- and the query becomes SELECT * FROM users WHERE username = '' OR 1=1 --'. Authentication bypassed. Data dumped. Tables dropped. Parameterized queries solve this completely — input is treated as data, never as code.

Vulnerable

Vulnerable
sql_concat.py
import sqlite3

conn = sqlite3.connect("users.db")
cur = conn.cursor()

username = input("Username: ")
query = f"SELECT * FROM users WHERE username = '{username}'"
cur.execute(query)
# An attacker types: ' OR 1=1 --

Secure alternative

Secure
task9SqlInjectionSafe.py
import sqlite3

conn = sqlite3.connect("users.db")
cur = conn.cursor()

username = input("Username: ")
cur.execute(
    "SELECT * FROM users WHERE username = ?",
    (username,)
)

user = cur.fetchone()
if user:
    print("User found:", user)
else:
    print("No user found")
What injection enables Authentication bypass · stealing data from other users · deleting tables or entire databases · privilege escalation · in some configurations, code execution on the database server.
// fin

Now go break things.
Then fix them properly.

Clone the repository, run each task locally, and try the attack vectors yourself. Security is a muscle — build it through practice.