Sploitus

Exploit for insecure-notes-api

githubexploit Β· 2026-08-13

Exploit Code

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