SAFAL: Trust, Secrets, and the Cost of Assuming Nobody Looks
I downloaded SAFAL expecting to spend a weekend reverse engineering it.
The objective was simple: understand how much of the system could be reconstructed from publicly available artifacts alone. SAFAL — the Student Assessment for Formative and Learning platform — is a CBSE initiative for conducting standardised assessments across affiliated schools. The installer was publicly available on GitHub. The application was distributed to schools across India. The trust model appeared straightforward.
Students authenticated. Teachers managed assessments. Administrators managed institutions. Central infrastructure coordinated data.
Nothing about the architecture suggested it would become a months-long project.
The first surprise was how much of the system could be reconstructed without source code.
The second was what that reconstruction revealed.
Understanding the Ecosystem
SAFAL is not a single application. It is a collection of trust relationships.
Schools run local deployments — a Spring WebFlux application bundled with a PostgreSQL instance and a Redis-backed session store, distributed as a Windows installer or Linux package. Teachers and students interact with those local deployments. Administrators manage institutional state. Data synchronises between distributed school installations and central infrastructure at cbsesafal.in.
At a high level:
+---------------------------+
| cbsesafal.in/safal |
| Central Services |
+-------------+-------------+
^
| Exam sync / BGQ sync
| Serial key auth
|
+---------------+---------------+
| Local SAFAL School Node |
| Spring WebFlux + PostgreSQL|
+---+-------------------+---+---+
^ ^
| |
Students Teachers / Admins This architecture makes trust the most important security property in the entire platform. Every finding described in this document ultimately traces back to one question: what is trusted, and why?
The school node trusts the central server to provide exam data. The central server trusts the school node to provide accurate exam results. Students trust the school node to authenticate them correctly. Teachers trust the role system to enforce privilege boundaries. The entire system trusts that the secrets embedded in distributed software remain secret.
None of those trust relationships held up under scrutiny.
Methodology
The assessment had three distinct testing scopes, and the boundary between them matters.
Static analysis covered the entire decompiled school-deployment codebase. The publicly distributed Windows installer — SAFAL-Windows-x64-v2.0.1.exe, from github.com/cbse-safal/Windows — was extracted, the embedded JAR decompiled using CFR, and the resulting Java source analysed statically. Every finding described in this writeup originated here.
Findings were not accepted on first observation. Whenever a result appeared significant, analysis stopped. The finding was treated as incorrect until proven otherwise. Code paths were retraced. Execution flows were rebuilt. Alternative explanations were explored. Entire sections of analysis were discarded when they failed revalidation.
Dynamic validation was conducted against a self-hosted instance built from that same installer, running on local infrastructure, against a database I controlled. To make the application bootable in isolation, minimal changes were required — the bundled PostgreSQL schema was extracted from the JAR, loaded into a containerised PostgreSQL 16 instance, and external service dependencies that prevented standalone startup were stubbed. The application logic itself was not modified. No production school deployment was tested. No real student, teacher, or institutional data was involved at any point. Every school-deployment finding in this writeup is therefore code-confirmed and HTTP-validated against an instance that mirrors the distributed codebase.
Live testing was limited to the central server at cbsesafal.in/safal, conducted during a CBSE-authorised off-season window with explicit written permission, and only against unauthenticated and B2B endpoints — not against any path that could expose student data.
This separation is deliberate. A code-confirmed finding becomes a production finding when the production deployment matches the analyzed code. SAFAL is distributed as a binary installer, so it should — but I am stating openly that I have not personally observed any school-deployment bug in a live school setting, and verification of remediation across the deployed estate is the disclosure recipient’s domain, not mine.
Finding 1 — The Authentication Path That Should Not Exist
The first major finding emerged while reconstructing authentication logic in AuthenticationService.java.
Most systems validate credentials through a single trust path: compare the submitted credential against the stored credential. SAFAL did not.
The authentication check accepted three independent conditions as valid login:
1. MD5(storedPasswordHash + sessionSalt) == submittedPassword
2. MD5(masterPassword + sessionSalt) == submittedPassword
3. login.isAuthenticated() == true Condition 1 is normal. Condition 3 is reset to false before processing and only set to true by the central server B2B authentication flow — not exploitable directly.
Condition 2 is the problem.
masterPassword is a deployment-wide secret loaded from the database at startup, with a hardcoded fallback in AppConfig.java:
private static String masterPasswd = "e34c0a4d6877f0925cc249b6b35b5a55"; This is an MD5 hash. It is the fallback value used when the database configuration entry is absent or blank. It ships in every copy of the installer, on GitHub, publicly downloadable by anyone.
The session salt — expression.get(1) in the code — is returned unauthenticated from GET /api/public/ctoken in every login flow. It is not a secret.
The attack is therefore:
- Download the installer from GitHub.
- Extract the JAR. Decompile
AppConfig.java. Read the hash. - Crack the MD5 offline. MD5 with no per-user salt is trivially reversible via rainbow tables.
- Call
GET /api/public/ctoken. Receive the session salt. - Compute
MD5(masterPassword + salt). - Submit that value as the password for any valid username.
- Receive an authenticated session.
The username does not need to be guessed. The school server’s GET /api/auth/bgq/getAllTeacherInfoBySchId and GET /api/auth/bgq/users endpoints return full teacher and student rosters — including usernames — to any authenticated session, with no role restriction and no organisational boundary check. A student can enumerate all teachers at any school. A teacher can enumerate all students at any school.
The master password bypass was confirmed live. The response was "Successful Login :)".
This finding became the foundation for every subsequent attack chain.
Finding 2 — When Roles Became Suggestions
Authorization systems answer a simple question: what is this user allowed to do? The answer should depend on the user’s role, enforced server-side, independent of client-supplied input.
While tracing account creation logic in UserService.addUsers1(), a different pattern emerged.
Role enforcement existed. The problem was where it existed relative to object normalisation.
The relevant check in validateUser():
if (15 == sessionUser.getRole() && user.getId() <= 0L) {
user.setRole(25); // force student role for teacher callers
} This fires only when user.getId() <= 0. The id field comes from the request body — it is client-controlled. The reset to zero happens later, in addUsers1():
.doOnNext(t -> t.setId(0L)) // after validateUser() has already run Sending id: 999 in the request body causes the role enforcement check to be skipped entirely. The id is then reset to zero before database insertion, so the account is created normally — just with whatever role the attacker specified.
There is no equivalent check for manager-level callers (role == 10). The role <= 10 block in validateUser() only validates concurrent session limits and status values. A manager can create an administrator account with id: 0 and role: 5 — no bypass required.
The full chain: teacher sends {id: 999, role: 5} → admin account created in one request.
Confirmed live. The database showed role=5 for the created account.
Finding 3 — Passwords That Refused To Stay Secret
By this point, authentication and authorization had already produced substantial findings. Credential handling was expected to be a sanity check.
Instead, it became another vulnerability class.
While tracing the bulk student import flow in CommonService.java, password handling involved two separate representations:
student.setPasswd(student.getPassword()); // plaintext
student.setPassword(Utility.getMd5Hash(student.getPassword())); // MD5 hash Both columns are persisted to the database. The passwd column stores the original plaintext password. The password column stores the MD5 hash.
The SQL query in UtilServiceRepository.getPartialUsersByOrganization() selects u.passwd explicitly:
SELECT u.id, u.username, u.passwd, u.firstname, ...
FROM public.users u
WHERE u.organization = :id AND u.status > 100 This query backs GET /api/auth/users/all, accessible to any teacher-level session. The response includes passwd — the plaintext password — for every user in the organisation.
Confirmed live:
username=252615082010 passwd='Test@15082010'
username=252699990001 passwd='Teacher@123' The significance is not merely that passwords are exposed. It is that this endpoint, combined with the master password bypass, creates a complete credential dump chain: authenticate as any teacher using the master password, call /api/auth/users/all, receive plaintext passwords for every student and teacher in the organisation.
Finding 4 — Security Through Birthdays
Default credentials are dangerous because they transform identity into authentication. The assessment identified a credential-generation model in CommonService.java:
String plainDob = values.get(3).replace("/", "").replace(" ", "");
String password = "Test@" + plainDob; Every student imported via the bulk import flow receives Test@DDMMYYYY as their initial password. A student born on 15 August 2010 receives Test@15082010.
The password is stored as an unsalted MD5 hash. The entire keyspace — Test@ followed by 8 digits — is 100 million combinations. On commodity hardware, this exhausts in under one second.
Date of birth is not a secret. It appears in school records, class lists, and the student roster endpoint described in Finding 5. The combination of a predictable password format, unsalted MD5 storage, and publicly accessible date-of-birth data means any student account is compromisable by anyone who knows when that student was born.
Findings 5 and 6 — The Boundary Problem
Systems containing multiple institutions must enforce organisational boundaries. Who is allowed to see whose data?
Two endpoints failed this check entirely.
GET /api/auth/bgq/getAllTeacherInfoBySchId?id=<schoolId> returns the full teacher roster — names, designations, internal identifiers — for any school ID. The implementation in BgqTeacherService.getAllTeacherInfoBySchId() calls getAuthenticationUser() to verify a session exists, then immediately queries the database with the caller-supplied schoolId without checking whether the caller belongs to that school.
GET /api/auth/bgq/users?id=<schoolId> does the same for students, returning names, email addresses, dates of birth, city, and state for every student at any school.
Both endpoints are accessible to any authenticated session — including a student session. A student at school A can retrieve the full teacher and student roster for school B, C, or any other school in India.
The date-of-birth data returned by the student roster endpoint directly enables Finding 4. The teacher identifiers returned by the teacher roster endpoint directly enable Finding 1. The findings are not independent — they form a chain.
Finding 7 — The Home Response Knows Too Much
After login, every user receives a home response from GET /api/auth/home. For non-administrator sessions, the organisation object is passed through removeImportantData() before being returned.
removeImportantData() nulls four fields: approvedDate, updatedAt, userCreationAllowed, version.
The following fields remain in the response for every student session: id, name, affiliationNo, type, contactPerson, mobile, email, address, city, state, pincode, status, concurrentSession, score, rank.
The principal’s name and mobile number are returned to every student who logs in. At scale, this enables harvesting of contact details for every school principal in the SAFAL network.
The Pattern
After enough findings accumulate, individual vulnerabilities stop being interesting. Patterns become interesting.
The recurring theme throughout the school server assessment was not poor cryptography, not weak passwords, not a single missing authorization check. The recurring theme was trust applied before verification.
Authentication trusted a secret that was not truly secret — it was embedded in publicly distributed software.
Authorization trusted a client-supplied field (id) before the server had normalised it.
Data access trusted a caller-supplied identifier (schoolId) without verifying the caller’s relationship to that identifier.
Credential storage trusted that a plaintext column would never surface in an API response.
Different findings. Same pattern. The system was built assuming that the people interacting with it would behave as intended. That assumption was never enforced.
From Local Deployments to Central Infrastructure
The second phase of the research focused on cbsesafal.in/safal — the central server that coordinates data between school deployments.
The most immediately impactful finding required no authentication at all.
POST /api/public/installerConfig accepts an affiliation number and returns the school’s schoolId and serialKey. No authentication. No rate limiting. CBSE affiliation numbers are public — they appear on school certificates and the CBSE website.
The serialKey is the sole credential protecting the exam synchronisation endpoint. It is the only thing that distinguishes a legitimate school server from an attacker.
In a sample of 50 consecutive affiliation numbers, 35 returned valid responses. Extrapolated across CBSE’s approximately 25,000 affiliated schools, this endpoint exposes the sync credentials for roughly 17,500 institutions.
The synchronisation endpoint itself — POST /api/public/sync/examData — accepts exam attempt and response data authenticated only by serialKey. The server validates the key but does not verify that the key belongs to the schoolId in the request. School A’s key is accepted for school B’s data.
This was confirmed by sending school 76606’s serial key with schoolId=69659 in the request. The server returned ECE-003: Exam not available — an application-level error indicating the request was authenticated and processed, not rejected. The same request with a wrong serial key returned HTTP 405.
The auth bypass is confirmed. The data injection requires a valid examId during an active exam window. Testing occurred during summer holidays — no active exams. The vulnerability is real; the impact window is bounded to exam periods.
The BGQ (Background Questionnaire) endpoints presented a related but distinct issue. POST /api/public/examportal/saveBgqStudentResponse processes requests with no authentication at all when the correct payload structure is provided. The server attempts a database update and returns an application-level error. Sending a different school’s schoolId produces the same application-level response — the server processes the request against that school’s data without any ownership check.
The teacher login endpoint — POST /api/public/examportal/login — implements a lockout after four failed attempts. The lockout fires correctly. It then immediately resets, allowing four more attempts. This cycle repeats indefinitely. The lockout duration increases with each cycle (15 minutes, 30 minutes, 60 minutes) but the counter always resets after triggering, making the protection ineffective against a patient attacker.
The same endpoint leaks user existence. A valid affiliation number returns "Invalid Credentials! N attempt left." A non-existent affiliation number returns "Invalid credentials! Please enter LOC/OASIS portal credentials". The error messages are different. An attacker can enumerate which affiliation numbers have active OASIS accounts without any credentials.
The central server’s authenticated surface — student credential files, roll lists, question papers, national exam reports — sits behind OASIS authentication. The brute-force bypass is the path to those endpoints. The user enumeration finding reduces the search space.
The Trust Model Failure
Stepping back from individual findings, the central server exhibits the same pattern as the school server.
The installerConfig endpoint trusted that affiliation numbers would only be submitted by legitimate school administrators. They are public.
The sync endpoint trusted that a valid serialKey implied a legitimate school server. It does not — the key is obtainable unauthenticated for any school.
The BGQ endpoints trusted that requests would come from authenticated school servers. They did not require authentication at all.
The lockout mechanism trusted that an attacker would not simply retry after being locked out. The reset behaviour made that assumption false.
The central server and the school server were built by different teams, at different times, with different codebases. They share the same architectural assumption: that the people and systems interacting with them will behave as intended.
Neither system enforced that assumption.
Disclosure
The findings were disclosed through CERT-In, which acknowledged the report and assigned a tracking reference.
The objective of disclosure was to fix the systems. The objective of this writeup is to document what was found, how it was found, and what it means for systems built on similar assumptions.
Closing Thoughts
The first version of this project existed entirely inside a decompiler.
The final version existed across architecture diagrams, HTTP traces, disclosure reports, validation notes, attack-chain analysis, and months of discarded assumptions.
The most surprising part was never the vulnerabilities themselves. It was how often the correct response to a finding was:
“That can’t possibly be right.”
And how often, after another round of analysis, it was.
The hardest part of security research is not finding vulnerabilities. It is convincing yourself that what you found is real, that you have not made an error, that the system genuinely works the way the code says it works.
When the code is what gets distributed, that’s the strongest evidence available short of touching production — and touching production was never on the table.
SAFAL is not an outlier. The findings here — hardcoded secrets in distributed binaries, authorization checks on unnormalised input, plaintext credential storage surfacing in API responses, missing organisational boundaries, unauthenticated endpoints processing sensitive operations — appear in enterprise software, government platforms, and critical infrastructure with regularity.
The question is not whether systems like this exist.
The question is how many of them have never been looked at.