Skip to content

Folders and files

NameName
Last commit message
Last commit date

Latest commit

 

History

2 Commits
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

MobileNotes API — a deliberately vulnerable demo backend

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 in vulnerable/ 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 to 127.0.0.1 only.


How to run it

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

1. Start the vulnerable server and attack it

# terminal A
python3 vulnerable/app.py                    # :8080

# terminal B
python3 poc.py -u http://127.0.0.1:8080

All 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.

2. Start the fixed server and confirm the attacks fail

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.py

Using poc.py

python3 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 (8081secure/, 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.


The vulnerabilities

VULN-1 — Hardcoded credentials and secrets

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.


VULN-2 — SQL injection (authentication bypass)

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"), 401

The 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 OR and -- is not a fix. Those blocklists are routinely bypassed. Parameterised queries remove the ambiguity instead of trying to guess which input is dangerous.


VULN-3 — OS command injection (remote code execution)

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
  1. 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.
  2. 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 to cp as 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.


VULN-4 — Path traversal

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"), 400

realpath() 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.


VULN-5 — Broken access control

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 token

Why 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"), 403

Two 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.


Summary

# 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.


Repository layout

.
├── 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.

About

Deliberately vulnerable Flask API with a fixed counterpart and a PoC script — SQL injection, command injection, path traversal, broken access control, hardcoded secrets.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages