A tiny REST API that acts as the backend of a note-taking mobile app: you log in, you read your notes, and an administrator can trigger a database backup.
It ships in two versions of the same application:
| Folder | Port | What it is |
|---|---|---|
vulnerable/app.py |
8080 | The application with the planted vulnerabilities |
secure/app.py |
8081 | The same application, fixed |
Both files keep the same structure and the same function names, so you can diff them side by side and see exactly which lines fix which problem.
⚠️ The code invulnerable/is insecure on purpose. It is teaching material. Do not deploy it, do not expose it to a network, and do not copy any of it into a real project. Both servers bind to127.0.0.1only.
Requirements: Python 3.9+. Two dependencies — Flask for the servers,
requests for the proof of concept. Everything else (sqlite3, subprocess,
hashlib) is in the standard library.
git clone <this-repo>
cd insecure-notes-api
python3 -m venv .venv
source .venv/bin/activate # Windows: .venv\Scripts\activate
pip install -r requirements.txt# terminal A
python3 vulnerable/app.py # :8080
# terminal B
python3 poc.py -u http://127.0.0.1:8080All five checks report EXPLOITED. Terminal A also logs the SQL query and the
shell command it builds, so you can watch a payload turn into an instruction.
The fixed build refuses to start until its secrets come from the environment — that refusal is part of the fix, not an error.
# terminal A
export MOBILENOTES_API_SECRET="$(python3 -c 'import secrets;print(secrets.token_hex(32))')"
export MOBILENOTES_ADMIN_PASSWORD='S3cure-Adm1n-Pass'
python3 secure/app.py # :8081
# terminal B
python3 poc.py -u http://127.0.0.1:8081 --password 'S3cure-Adm1n-Pass'All five checks report BLOCKED.
Compare the two files directly with:
diff -u vulnerable/app.py secure/app.pypython3 poc.py [-u URL] [--username USERNAME] [--password PASSWORD]
-u, --url target base URL (default: http://127.0.0.1:8080)
--username admin username (default: admin)
--password admin password (required for the fixed build)
-h, --help show usage and examples
Each check prints the payload sent, the server's raw answer, and a verdict:
[VULN-2 SQL injection]
payload {"username": "admin' --", "password": "wrong-password"}
response {"role":"admin","token":"37e2ad745…","username":"admin"}
notes {"notes":[{"body":"prod backup api key = sk_demo_not_a_real_key"}]}
verdict EXPLOITED
EXPLOITED means the attack worked; BLOCKED means the server rejected it.
Why --password is required against the fixed build. VULN-3, VULN-4 and
VULN-5 all target /api/backup, which needs a session. On the vulnerable build
the SQL injection hands one over for free. On the fixed build it does not, and
the endpoint is admin-only there — so without a real login every payload would
stop at 403 forbidden, before the input is even parsed. That would print
BLOCKED for the wrong reason. The script therefore stops immediately and asks
for a password rather than reporting a verdict it cannot justify.
VULN-1 is the one check that is not a request: hardcoded secrets are a property
of the source code, not of a response, so the script reads the app.py that
belongs to the port it is aimed at (8081 → secure/, anything else →
vulnerable/).
If every check suddenly returns 500 Internal Server Error, the run/
folder was deleted while a server was running. The database is only created at
startup, so restart both servers and they will rebuild it.
What it is. Passwords and API keys are typed directly into the source code:
API_SECRET = "s3cr3t-signing-key-do-not-share"
ADMIN_PASSWORD = "Adm1n!2024"
BACKUP_SERVICE_API_KEY = "sk_demo_not_a_real_key"Why it is a risk. A secret written in code is not really a secret. It is copied into every clone of the repository and into every build, and it stays in the git history forever — deleting the line later does not remove it, because anyone can read the older commit. This matters even more for a mobile app: the app is downloaded onto the attacker's own phone, and an APK or IPA is just an archive that can be unzipped and read with free tools.
What could happen. Anyone who sees the code gets the admin password and a live payment-provider key. Rotating them means a code change and a new release for every user, so in practice they tend to stay valid for a long time. Automated scanners crawl public repositories looking for exactly these strings.
No exploitation needed — you just read the file:
$ grep -n -E "API_SECRET|ADMIN_PASSWORD|API_KEY" vulnerable/app.py
9:API_SECRET = "s3cr3t-signing-key-do-not-share"
11:ADMIN_PASSWORD = "Adm1n!2024"
12:BACKUP_SERVICE_API_KEY = "sk_demo_not_a_real_key"The fix. Load secrets from the environment (or a secret manager) at startup, and refuse to start when one is missing rather than falling back to a default. Never commit them; keep them out of the client entirely, since anything shipped to a device must be assumed to be public.
# secure/app.py
def require_env(name):
value = os.environ.get(name)
if not value:
sys.exit(f"error: environment variable {name} is required, refusing to start")
return value
API_SECRET = require_env("MOBILENOTES_API_SECRET")The same habit applies to the stored passwords: vulnerable/app.py keeps them in
plaintext, while secure/app.py stores salted PBKDF2-HMAC-SHA256 hashes, so a
stolen database no longer hands the attacker a ready-to-use password list.
What it is. The login query is built by gluing user input into a string, so whatever the user types becomes part of the SQL command itself:
query = (
"SELECT id, username, role FROM users "
f"WHERE username = '{username}' AND password = '{password}'"
)
row = db.execute(query).fetchone()Why it is a risk. The database cannot tell the difference between the
instructions the developer wrote and the text the user supplied — it just sees
one sentence. A username of admin' -- closes the quote early and turns the
rest of the line, including the whole password check, into a comment:
SELECT id, username, role FROM users WHERE username = 'admin' --' AND password = 'wrong-password'What could happen. Login without a password, as any account, including the administrator. With a different payload the same flaw can be used to read every row in the database — the full user table, every private note — and on some database engines to write files or run commands on the server.
Proof of concept:
$ curl -s -X POST http://127.0.0.1:8080/api/login \
-H 'Content-Type: application/json' \
-d '{"username": "admin'"'"' --", "password": "wrong-password"}'
{"role":"admin","token":"afb7c3ae93ebb3f6b56b6ea4581d8619","username":"admin"}
$ curl -s http://127.0.0.1:8080/api/notes -H "Authorization: Bearer afb7c3ae…"
{"notes":[{"body":"prod backup api key = sk_demo_not_a_real_key", …}]}We are logged in as admin with a deliberately wrong password, and the admin's
private note leaks a production API key.
The fix. Use a parameterised query. The ? placeholder keeps the value in a
separate channel from the statement, so the database always treats it as data,
never as SQL. admin' -- is then looked up as a literal username, matches
nothing, and the login fails as it should.
# secure/app.py
row = db.execute(
"SELECT id, username, role, password FROM users WHERE username = ?",
(username,),
).fetchone()
if row is None or not verify_password(password, row[3]):
return jsonify(error="invalid credentials"), 401The password never touches the query at all: it is verified in Python against a salted hash, using a constant-time comparison.
Note: escaping quotes by hand or filtering words like
ORand--is not a fix. Those blocklists are routinely bypassed. Parameterised queries remove the ambiguity instead of trying to guess which input is dangerous.
What it is. The backup endpoint builds a shell command out of the file name the user sends, and hands it to a shell:
command = f'cp "{DB_PATH}" "{BACKUP_DIR}"/{name}'
result = subprocess.run(command, shell=True, capture_output=True, text=True)Why it is a risk. With shell=True, the string is interpreted by /bin/sh
before anything runs, and the shell treats characters like ;, |, && and
$(…) as syntax. A name of pwned; id is not one command any more — it is two.
What could happen. This is the most severe of the five: the attacker runs their own commands with the privileges of the server process. That means reading configuration files and the database, stealing cloud credentials from the instance metadata service, opening a reverse shell, or installing persistence — and from there, moving deeper into the internal network.
Proof of concept:
$ curl -s -X POST http://127.0.0.1:8080/api/backup \
-H 'Content-Type: application/json' \
-H "Authorization: Bearer $TOKEN" \
-d '{"name": "pwned; id"}'
{"command":"cp \"…/notes.db\" \"…/backups\"/pwned; id",
"exit_code":0,
"output":"uid=501(user) gid=20(staff) groups=20(staff),12(everyone),…"}The output field is the result of id — a command the attacker chose, not the
developer. Note that this is reached with the token obtained from VULN-2, so the
two flaws chain: no password → admin session → code execution on the server.
The fix. Drop the shell and allow-list the input:
# secure/app.py
SAFE_BACKUP_NAME = re.compile(r"^[A-Za-z0-9_-]{1,32}$")
if not isinstance(name, str) or not SAFE_BACKUP_NAME.match(name):
return jsonify(error="invalid backup name"), 400 # allow-list
result = subprocess.run(["cp", DB_PATH, destination], shell=False) # no shell- Allow-list the input. Accept only letters, digits, dash and underscore. Describing what is allowed is safe; trying to list everything that is forbidden is a game you lose eventually.
- Drop the shell. With an argument list and
shell=False, there is no shell left to interpret;, so even an input that slipped past step 1 is passed tocpas one ordinary file name.
Better still, do not shell out at all — shutil.copyfile() does this job in pure
Python with no external process involved.
What it is. The same backup name is pasted into a file path with no check on where that path ends up:
command = f'cp "{DB_PATH}" "{BACKUP_DIR}"/{name}'Why it is a risk. .. means "go up one folder". A name of ../escaped
resolves to a location outside the backup directory, and the server writes
there just as happily.
What could happen. The attacker chooses where a file lands: overwrite a config file, drop a script into a directory the web server executes, or plant an SSH key. The database being copied is itself sensitive, so the flaw also moves private data to a place the attacker can reach.
Proof of concept:
$ curl -s -X POST http://127.0.0.1:8080/api/backup \
-H 'Content-Type: application/json' \
-H "Authorization: Bearer $TOKEN" \
-d '{"name": "../escaped"}'
{"command":"cp \"…/run/notes.db\" \"…/run/backups\"/../escaped","exit_code":0,"output":""}exit_code: 0 means the copy succeeded — the database now sits one level above
the folder it was supposed to stay in. A real attacker would keep going with
../../ until they reach a directory that matters.
The fix. Resolve the final path and confirm it is still inside the intended folder before using it:
# secure/app.py
destination = os.path.realpath(os.path.join(BACKUP_DIR, name))
if os.path.dirname(destination) != os.path.realpath(BACKUP_DIR):
return jsonify(error="invalid backup name"), 400realpath() matters here: it collapses .. and follows symlinks before the
comparison, so a name that only looks safe cannot slip through. The allow-list
from VULN-3 already blocks .. as well — two independent layers, because one
check can always be bypassed by an input nobody thought of.
What it is. The backup endpoint checks who you are, but never whether you are allowed:
user = current_user()
if user is None:
return jsonify(error="unauthorized"), 401 # only asks: are you logged in?
# ...runs the backup for anybody with a valid tokenWhy it is a risk. Authentication and authorisation are different questions.
The server knows perfectly well that the caller is alice, a normal user — it
simply never asks whether she may trigger an administrative action. This class is
A01: Broken Access Control in the OWASP Top 10, and API5: Broken Function Level Authorization in the API Top 10.
What could happen. Any registered user performs admin-only operations. Here that means dumping the whole database, which contains every other user's notes. In a mobile app the mistake is often invisible from the outside: the client hides the admin button, so the feature looks protected — but the attacker holds the device and can call the API directly.
Proof of concept:
$ ALICE=$(curl -s -X POST http://127.0.0.1:8080/api/login \
-H 'Content-Type: application/json' \
-d '{"username":"alice","password":"wonderland"}' | python3 -c "import json,sys;print(json.load(sys.stdin)['token'])")
$ curl -s -X POST http://127.0.0.1:8080/api/backup \
-H 'Content-Type: application/json' -H "Authorization: Bearer $ALICE" \
-d '{"name": "alice-backup"}'
{"command":"cp \"…/notes.db\" \"…/backups\"/alice-backup","exit_code":0,"output":""}Alice is a normal user, and the backup ran anyway.
The fix. Check the permission, not just the session:
# secure/app.py
if user["role"] != "admin":
return jsonify(error="forbidden"), 403Two details worth noting. The status codes are not interchangeable: 401 means
I do not know who you are, 403 means I know who you are, and you may not do
this. And the role is read from the session the server itself created at login,
never from the request — a client that adds {"role":"admin"} to the body, or an
X-Role: admin header, changes nothing, because the server never looks there.
| # | Vulnerability | Impact | Fix |
|---|---|---|---|
| 1 | Hardcoded credentials and secrets | Admin password and live API key readable by anyone with the code or the app package | Read secrets from the environment; fail closed when missing; hash stored passwords |
| 2 | SQL injection in /api/login |
Log in as any user without a password; read the whole database | Parameterised queries; verify hashed passwords in code |
| 3 | Command injection in /api/backup |
Run arbitrary commands on the server | Allow-list the input and run without a shell |
| 4 | Path traversal in /api/backup |
Write files outside the backup folder | Resolve the path and confine it to the intended directory |
| 5 | Broken access control in /api/backup |
Any normal user performs an admin-only action | Check the caller's role, not just the session |
The thread running through all five: never mix untrusted input with a command you are about to execute — whether that command is SQL, a shell line or a file path — never trust the client to enforce a permission, and never assume that something shipped to a client stays private.
.
├── vulnerable/app.py # the insecure version, markers # VULN-1 .. VULN-5
├── secure/app.py # the fixed version, markers # FIX-1 .. FIX-5
├── poc.py # proof of concept, one verdict per vulnerability
├── requirements.txt # Flask (servers) and requests (poc.py)
└── README.md
Runtime files (the SQLite database and the backups) are written to run/, which
is git-ignored and recreated every time a server starts.