## https://sploitus.com/exploit?id=5427A7F4-95ED-5419-AB9E-6EC577C3E361
# 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.
```bash
git clone
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
```bash
# 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.
```bash
# 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:
```bash
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 (`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.
---
## The vulnerabilities
### VULN-1 β Hardcoded credentials and secrets
**What it is.** Passwords and API keys are typed directly into the source code:
```python
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:
```console
$ 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.
```python
# 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:
```python
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:
```sql
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:**
```console
$ 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.
```python
# 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:
```python
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:**
```console
$ 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:
```python
# 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:
```python
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:**
```console
$ 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:
```python
# 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*:
```python
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:**
```console
$ 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:
```python
# 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.