An Exposed Log File, Live Admin Sessions and 50,000 Records
TL;DR
- Replaying a leaked staff session token gave read access to an API covering over 50,000 records. The returned case data included full names, dates of birth, passport numbers, another government-issued reference number, email addresses and phone numbers.
- The token came from a production log that anyone could download without logging in. Across 18 days, the logs exposed 149 session tokens, over 3,500 record IDs and staff reviewer comments.
- The data API accepted the same credentials that the application's session validation and keepalive endpoints rejected.
- The token still worked more than nine hours after its last log entry, despite a one-hour inactivity timer in the admin panel's JavaScript.
- The report was accepted, and I confirmed the fix on retest. Getting the $400 bounty took three months and two follow-ups.
Hostnames, application and endpoint names, procedure names, IDs and tokens below have been replaced or redacted. The directory names, log filename pattern, dates and responses are from the report. I'm keeping the vendor, sector and reporting platform private.
Introduction
I found this while doing content discovery on a bug bounty scope with dozens of hosts. They ran similar web forms behind a shared admin panel. /forms/<APP>/logs/ returned 403, so I tried a few filenames inside it.
The log filenames followed the pattern logFile_DDMMYY.txt. I generated candidates for recent dates:
BASE="https://<HOST>/forms/<APP>"
for d in 260526 250526 240526 260125; do
curl -sko /dev/null -w "%{http_code} $d\n" "$BASE/logs/logFile_$d.txt"
done
200 260526
200 250526
200 240526
404 260125
After the first 200, I worked backwards through the dates and found 18 days of logs going back to 4 May, about 1.6 MB in total. I could read them without logging in, and the current day's file was still growing.
There was also a UAT copy of the app on the same host with a log at /forms/UAT/<APP>/logs/logFile_261224.txt, dating back to December 2024. The other forms I checked didn't expose logs at the same paths.
Session Tokens in the Log
The files contained stored procedure calls with all their arguments. One document-review entry looked like this:
26-May-2026 10:59:48-V1 :: UpdateDocs-Query::CALL ProcUpdateDoc (21,5120,'0f1e2d3c4b5a69788796a5b4c3d2e1f0','<document hash>',<request id>,<detail id>,<doc id>,'6','0','<reviewer comment>') ::
The admin panel's JavaScript identified the first three arguments as the user ID, session ID and session token. A batch-assignment entry also dumped a PHP object with a sessionToken field, matching the name used by the API client.
Across the files, there were 149 distinct tokens and over 3,500 record IDs with reviewer comments. Those comments included staff explanations for rejecting submitted documents.
I followed the request format in the admin panel's JavaScript to check whether a token could be reused. The API expected JSON sent as Content-Type: text/plain, with the token under sessionToken. Using application/json or the name session_token returned a generic 400.
The read request used the session ID and token from the log:
SID=5120
TOKEN=0f1e2d3c4b5a69788796a5b4c3d2e1f0
curl -s -X POST "https://<HOST>/forms/<APP>/admin/api/CaseList/" \
-H "Content-Type: text/plain" \
-d "{\"session_id\":\"${SID}\",\"sessionToken\":\"${TOKEN}\"}"
{
"status": 1,
"record": [{ "id": "[REDACTED]", "email": "[REDACTED]", "ip": "[REDACTED]" }],
"details": [{ "firstName": "[REDACTED]", "lastName": "[REDACTED]", "dateOfBirth": "[REDACTED]", "passportNo": "[REDACTED]", "phone": "[REDACTED]" }],
"documents": [{ "filename": "[REDACTED].pdf", "docId": "[REDACTED]" }]
}
Session Validation
When the admin panel loads, it checks the session through Validate. It also has a keepalive endpoint, Heartbeat. Both rejected the credentials that had just worked on the data API.
These requests used the same token and session ID, in the same second:
| Endpoint | What it's for | Response |
|---|---|---|
Validate | Session check the admin UI runs on load | status: 2 (invalid session) |
Heartbeat | Keepalive ping | status: 2 |
CaseList | Cases assigned to this agent | status: 1 (full PII) |
CaseSearch | Search every record in the system | status: 1 (full PII) |
DocHistory | Document review history | status: 1 |
AdminCases | Admin-only case list | status: 3 (role check failed) |
The role error from AdminCases suggested that endpoint recognised the account. Whatever check Validate and Heartbeat were failing, it didn't stop the read endpoints returning data.
The same token still worked at 20:27 UTC. Its last log entry was at 11:12, more than nine hours earlier.
The JavaScript had a one-hour inactivity timer that sent a logout POST. That depends on the page staying open long enough to send the request, so it can't enforce the timeout after someone closes the tab.
Impact
The search endpoint accepted an empty search term and reported a total of over 50,000 records. I couldn't create a record of my own because the app didn't allow self-registration, so I used an ID from the log and limited the query with rows: 1. I retrieved one record, redacted it and stopped.
That record included:
- Full name, date of birth, passport number, another government-issued reference number, email address and phone number.
- The submitter's IP address, submission timestamps, processing status and assigned staff member.
- Uploaded document filenames and IDs.
File downloads required a separate document key from Validate, which rejected the leaked credentials. I didn't demonstrate access to the uploaded files. The JavaScript also referenced calls to approve or reject a case, reassign it and change its processing location, using the same session fields. I didn't test those write operations.
I found three reference numbers from live records hardcoded in the JavaScript too.
Remediation
The logs needed to move outside the web root, with credentials removed from future log entries and the exposed tokens revoked. The data endpoints needed to check session validity and expiry on every request.
Disclosure Timeline
- May 26, 2026: Logs found during content discovery. Reported the same day, with the exposure and the session validation gap as a single issue.
- June 15, 2026: Triaged and accepted. I was asked to retest, and confirmed the fix.
- July 8, 2026: Asked for an ETA on the bounty. No response.
- August 3, 2026: Asked again, and whether the program's advertised bounty for exposure of personal data ($1500) applied here.
- August 28, 2026: Awarded $400 and the report closed.
It took three months and two follow-ups to get paid, and the bounty was pretty low considering the impact. Needless to say, I won't be returning to this program!