DPS Wizzgeeks: What a Student Account Can Do
I started with a student account and a login page.
The objective was to understand what the application actually enforced versus what it merely displayed. DPS Wizzgeeks — dps.wizzgeeks.com — is a learning portal serving Delhi Public School students across multiple branches. Students take adaptive tests. Teachers manage assessments. HRDC administrators oversee institutional data. The architecture looked standard.
The first session lasted about forty minutes. By the end of it, I had the full name, email address, grade, section, and branch of every student on the platform.
That was just the beginning.
Understanding the System
The application is a React SPA backed by a Python/Flask API and MongoDB Atlas. Cloudflare sits in front. AWS S3 stores uploaded content. Gemini 2.5 Flash handles AI-powered question generation and answer evaluation.
The trust model, as designed:
+---------------------------+
| dps.wizzgeeks.com |
| Flask API + MongoDB |
+-------------+-------------+
^
| JWT auth
|
+-------------------+-------------------+
| | |
Students Teachers Admins
(lowest priv) (mid priv) (full priv) Students see their own dashboard, take tests, view their results. Teachers manage assessments, view student performance, enable chapters. Admins manage users, generate reports, access everything.
The application enforces this hierarchy in the React frontend. The server enforces it on approximately three of the thirty-plus endpoints I tested.
Finding 1 — The Entire User Directory
The first thing I did after logging in was look at what the API returned.
GET /api/api/hrdc/students — intended for HRDC administrators — returned 2,956 student records with my student token. No role check. The response included username, email, grade, section, branch, institution, and time-on-platform metrics for every student in the system.
The same pattern held for teachers (122 records), principals (19 records), and the HRDC dashboard itself. Every administrative endpoint under /hrdc/* responded to a student JWT with HTTP 200 and full data.
GET /api/api/user/user/getbyrole?role=admin returned all seven administrator accounts, including their internal MongoDB ObjectIds.
The application had built a complete role-based UI. The server had not built the corresponding role-based access control.
Finding 2 — The Admin UI That Shouldn’t Have Loaded
The role enforcement gap ran deeper than missing server-side checks. The application stored the user’s role in localStorage and used it to decide which UI components to render.
Setting localStorage.role = "admin" in the browser console caused the admin dashboard to load. This was not an exploit in itself — the server still rejected admin-only API calls. But it was a map.
The admin UI revealed endpoints and workflows that were not visible from the student interface. It showed what the application thought an admin could do. And it raised the obvious question: how many of those admin-only endpoints actually checked the caller’s role?
The answer, as it turned out, was not many.
Finding 3 — Teacher Actions Without a Teacher Account
The admin UI pointed toward teacher-specific endpoints. Testing them with a student token produced an unexpected result: most of them worked.
POST /api/api/user/teacher/enabled-chapters — the endpoint teachers use to enable or disable curriculum chapters for their students — accepted a student JWT. No role check. No ownership check on the teacher_id parameter. A student could disable all chapters for any teacher on the platform, cutting off every student assigned to that teacher from their adaptive tests.
GET /api/api/teacher/dashboard?teacher_id=X returned any teacher’s full performance dashboard — total students, top performers, assessment counts — with a student token and an arbitrary teacher ID.
GET /api/api/assessment/getaddQuestions/{id} — an endpoint for teachers and admins to view the questions in an assessment — returned the full question data including the answer field for every question. Student token. No role check.
The server had not asked whether the caller was a teacher. It had assumed they were.
Finding 4 — The Exam That Could Not Be Failed
The answer leakage from getaddQuestions combined with the submission endpoint to produce something straightforward.
POST /api/api/assessment/submitAssessment/{id} accepted total_time: 5 for a thirty-minute exam. It enforced no minimum time. It enforced no attempt limit. Three concurrent submissions to the same assessment created three distinct result records.
The chain was two API calls:
# Get all correct answers
answers = GET /assessment/getaddQuestions/{id} # student token, returns answer field
# Submit instantly with 100% correct answers
POST /assessment/submitAssessment/{id}
{"results": [{"question_id": "...", "user_answer": "B", "time_spent": 5}], "total_time": 5}
# → {"total_correct": 10, "percentage": 100, "pass_status": "pass"} The same vulnerability existed across physics assessments, maths assessments, and practice tests. The viewAssessmentAnswers endpoint returned correct answers regardless of whether the student had started the exam. The downloadQuestionPaper endpoint returned the full question paper PDF without requiring an active attempt.
Academic integrity on this platform was entirely client-enforced.
Finding 5 — Persistence
At this point the engagement had a student account, access to teacher-level endpoints, and a complete picture of the user directory. The next question was persistence: could a student create a durable foothold that survived a password change or session expiry?
POST /api/api/user/user/bulkcreate accepts a role parameter and an xlsx file. The server does not validate that the requested role is subordinate to the caller’s role. A student submitting role=reviewer received the same response as an administrator doing the same thing:
{"message":"Bulk user creation completed","created_count":1,"created_users":["pentest.teacher"]} Every account created this way received the hardcoded default password welcome123. Login was immediate.
The same endpoint accepted role=admin and role=hrdc. The entire privilege hierarchy collapsed into a single API call and a spreadsheet.
A reviewer account could create and approve questions — and upload arbitrary files to S3. A teacher account could manage assessments and access student data. An admin account could do everything.
The persistence question was answered. The new account existed independently of the original student session.
Finding 6 — Six Ways to Host Malicious JavaScript
The reviewer account opened a new surface: file uploads.
Six upload endpoints accepted arbitrary file types. All six stored files in a public AWS S3 bucket. All six served uploaded files with the content-type of the uploaded file.
Uploading a .js file produced a public S3 URL with Content-Type: text/javascript. The question explanation fields — rendered via dangerouslySetInnerHTML in the React frontend — accepted <script src="..."> tags. A reviewer could upload a credential-stealing script to S3, inject the URL into a question explanation, and have it execute for every user who viewed that question.
Five of the six upload endpoints were accessible to student tokens directly, without needing the reviewer escalation. The prior engagement had documented one such endpoint, accessible to admin and reviewer roles. This engagement found five more.
Finding 7 — The Question Bank as an Attack Surface
The question creation endpoints — /maths-question/create, /physics2-question/create, /maths-question/casebased/create — accepted student tokens with no role check. The explanation and case_description fields accepted arbitrary HTML, stored it unescaped, and returned it in list endpoints rendered via dangerouslySetInnerHTML.
A student could poison the question bank with payloads that fired for every user who viewed those questions. The exam submission endpoint stored whatever the student submitted as user_answer without sanitization. When a teacher opened the answer review page, any HTML in a student’s submitted answer executed in their browser.
The authToken cookie was not HttpOnly — it was set via document.cookie in the application’s login flow. Any XSS that fired in a teacher’s session could steal it.
Finding 8 — The Token That Should Not Have Been There
The profile endpoint — GET /api/api/user/user/get — included two fields that had no business being in an API response:
"reset_token": "lyt28ftGtPq1IcivXEF8vN8HfQP1YJo-swZ4ojLj5Ko",
"reset_token_expires_at": "2026-05-29T07:29:04.542000" The reset_token is the raw value stored in MongoDB when a user requests a password reset. It is the credential that proves email access. It is what gets sent in the reset link. It should never leave the server.
With any valid JWT — including the reviewer account created in Finding 5 — the chain was:
- Trigger a password reset for the target:
POST /forgot-password/request - Read the
reset_tokenfrom their profile:GET /user/user/get - Exchange it for a
change_password_token:POST /forgot-password/reset/verify - Reset their password:
POST /forgot-password/reset-password
The target’s old password stopped working. The attacker owned the account. No email access required.
This was a complete ATO chain. But it required a JWT for the target account — which meant it required either the XSS path from Finding 7, or a separate way to obtain credentials.
The goal throughout had been something more direct: a deterministic, O(1) account takeover that required nothing but an email address.
Finding 9 — The Database That Answered the Wrong Question
The password reset flow used two tokens. The first — reset_token — proved email access. The second — change_password_token — authorized the actual password change. The design was presumably intended to add a verification step.
The problem was in the verification endpoint.
POST /api/api/user/forgot-password/reset/verify passed the reset_token field directly to MongoDB’s find_one() query without checking that it was a string. MongoDB operator objects were accepted and executed.
The server returned three distinct responses depending on what the query matched:
- Fresh token found:
{"verified": true, "change_password_token": "..."} - Expired token found:
{"verified": false, "message": "Reset token has expired"} - Nothing found:
{"verified": false, "message": "Invalid or expired token"}
This is a blind injection oracle. The difference between “expired” and “invalid” tells you whether the operator matched a document.
{"$regex": "^s"} asks MongoDB: does any reset_token in the collection start with the letter s? If the answer is yes and the token is fresh, the server returns verified: true and hands over the change_password_token.
The attack requires no knowledge of the token. It requires no JWT. It requires only the victim’s email address — which is already leaked for all 2,956 students via the HRDC endpoint from Finding 1.
# Trigger reset for target
POST /forgot-password/request {"email": "victim@example.com"}
# Scan each character until verified:true fires
for c in string.ascii_letters + string.digits + "-_":
r = POST /forgot-password/reset/verify {"reset_token": {"$regex": f"^{c}"}}
if r.json().get("verified") == True:
change_token = r.json()["change_password_token"]
break
# Reset password with the obtained token
POST /forgot-password/reset-password
{"change_password_token": change_token, "new_password": "attacker_set", ...}
# → {"message": "Password reset successfully"} I ran this against pentest_teacher_final@example.com twice. Both times, the oracle found the correct character on the first scan, the password reset succeeded, and login with the new password confirmed the takeover. The account was restored after each demonstration.
The backend is MongoDB Atlas. Atlas disables $where and $function — JavaScript execution operators — at the infrastructure level. This is why the login endpoint’s NoSQL injection (which also accepted operator objects in the password field) could not be escalated further: the password reached bcrypt.hashpw(password.encode()) before any query, and bcrypt rejected non-string input with a Python exception. The injection surface was real; the code path was wrong for exploitation.
The reset endpoint had the right code path. The operator reached find_one() directly.
The Pattern
After enough findings accumulate, individual vulnerabilities stop being interesting. The pattern becomes interesting.
The recurring theme throughout this assessment was not poor cryptography, not a single missing authorization check, not one bad endpoint. The recurring theme was the server trusting the client to behave as intended.
The HRDC endpoints trusted that only HRDC-role tokens would call them. They did not check.
The bulkcreate endpoint trusted that callers would only request roles subordinate to their own. It did not check.
The teacher endpoints trusted that only teachers would call them. They did not check.
The exam submission endpoint trusted that students would submit answers after spending time on the exam. It did not check.
The profile endpoint trusted that reset_token was safe to return because only the account owner would call it. It did not consider that the account owner’s JWT could be obtained through the escalation chain.
The reset verification endpoint trusted that reset_token would be a string. It did not validate the type before querying MongoDB.
These are not nine independent failures. They are nine expressions of the same design assumption: that the application’s users would interact with it through the application’s interface, following the application’s rules.
The admin UI revealing hidden endpoints was not an exploit. It was a symptom. The application had been built with the assumption that users would only see what they were shown. The server had been built on the same assumption.
What Was Not Found
The backend is Python/Flask on MongoDB Atlas. Atlas disables JavaScript execution operators at the infrastructure level, which closed the most obvious path from NoSQL injection to server-side code execution. No Flask debug console was accessible. No SSTI surface was found — template expressions stored in question fields were returned as literals, not rendered. No path traversal was found — file uploads go to S3, not the server filesystem. No SSRF was confirmed — the PDF generation endpoint (HeadlessChrome) was restricted to admin and teacher roles, and the AI endpoints accepted only base64-encoded images, not URLs.
The phone number field (mobilenumber) was the one piece of PII that was correctly scoped. It appeared only in the account owner’s own profile response, not in any bulk listing or IDOR path.
The change-password endpoint accepted a userid parameter in the request body. The prior report had flagged this as a potential IDOR. It was not — the server ignored the parameter entirely and always changed the token owner’s password. The finding was removed.
Closing Thoughts
The application was called dps-frontend-poc in its webpack build configuration. That name appeared in the source map, which was publicly accessible at the production URL.
A proof of concept promoted to production without the security hardening that production requires. The name was accurate.
The most striking part of this engagement was not any individual finding. It was the consistency. Thirty-plus endpoints tested. Role checks enforced on approximately three. The server had been built to serve data efficiently. The question of who should receive that data had been answered in the frontend, where the answer could be changed by anyone with a browser console.
The blind NoSQL injection was the most technically interesting finding. But it required the most setup — a fresh reset token, a scan loop, immediate use of the result. The privilege escalation required two API calls and a spreadsheet. The mass PII dump required one.
The goal throughout was a deterministic, O(1) account takeover requiring nothing but an email address. Finding 9 delivered it. The path to get there — bad RBAC, a client-side role store that revealed the admin surface, teacher endpoints that didn’t check for teachers, a persistence mechanism that handed out any role on request — was the more interesting story.
The question is not whether systems like this exist. The question is how many of them have a student account available for anyone who asks.