diff --git a/umassctf-2026/crypto/Hens and Roosters/README.md b/umassctf-2026/crypto/Hens and Roosters/README.md new file mode 100644 index 00000000..f970eca4 --- /dev/null +++ b/umassctf-2026/crypto/Hens and Roosters/README.md @@ -0,0 +1,172 @@ +# Hens and Roosters + +## Summary + +Please help me buy more Legos! The store has such aggressive rate limiting I can't even get an ID! + +The challenge presents a web application where users must accumulate seven "studs" to purchase a Lego set and obtain the flag. Studs are earned by submitting valid signatures for the current stud count. While the server provides free signatures for the first three studs (0, 1, and 2), reaching seven requires bypassing rate limits, exploiting a race condition, and leveraging a cryptographic property of the signature scheme to "clone" valid signatures. + +**Artifacts:** + +- `backend/app.py`: Flask application handling user progress and signature verification. +- `backend/uov.py`: Implementation of the Unbalanced Oil and Vinegar (UOV) signature scheme. +- `backend/public_key.sobj`: Serialized SageMath object containing the UOV public key matrices. +- `backend/Dockerfile`: Container definition for the Flask backend. +- `proxy/haproxy.cfg`: HAProxy configuration with a vulnerable rate-limiting rule. +- `proxy/Dockerfile`: Container definition for the HAProxy reverse proxy. +- `compose.yaml`: Docker Compose file orchestrating the backend, proxy, and Redis services. +- `solve.py`: Exploit script demonstrating the full attack chain. + +## Context + +The application uses the **Unbalanced Oil and Vinegar (UOV)** signature scheme over the extension field $\mathbb{F}_{2^7}$. UOV is a multivariate public-key signature scheme. The signer partitions $n$ variables into $v$ "vinegar" variables (chosen at random) and $m$ "oil" variables (solved for). The public key is a set of $m$ multivariate quadratic polynomials over a finite field; the private key is the linear transformation $T$ that maps the oil/vinegar structure to the public variables, enabling efficient signing. Verification checks that the signature satisfies all $m$ public polynomial equations. Security relies on the hardness of solving random systems of multivariate quadratic equations (the MQ problem). A user is identified by a `uid`, and their progress (number of studs) is stored in Redis. + +To gain a stud, a user must `POST` a valid signature for the payload `str(studs) + '|' + uid` to the `/work` endpoint. The server implements a caching mechanism in Redis to speed up verification of recently seen signatures: + +```python +@app.post('/work') +def work(): + # ... read studs and payload ... + value = r.get(str(sig)) + if value is None: + r.set(sig, b'-', ex=240) # Reserve slot; blocks re-submission of same sig + verified = uov.verify(payload, sig_bytes) # Slow path (~2.5s) + if verified: + r.set(sig, payload, ex=240) + elif value == b'-': + return "The signature is still being processed, please send a request later!" + else: + verified = value.decode() == payload # Fast path (cache hit) + + if verified: + studs = r.incr(uid) + # ... return next free signature if studs <= 2 ... +``` + +The server is protected by HAProxy, which enforces a rate limit of one request every 20 seconds. + +## Vulnerability + +Three distinct vulnerabilities are chained to achieve the exploit. + +### 1. HAProxy Rate-Limit Bypass - [CWE-837: Improper Enforcement of a Single, Unique Action](https://cwe.mitre.org/data/definitions/837.html) + +The HAProxy configuration tracks request rates based on the full URL, including query parameters: + +```haproxy +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 } +``` + +By appending unique query parameters (e.g., `/work?x=1`, `/work?x=2`), an attacker can force HAProxy to treat each request as a distinct URL, effectively bypassing the rate limit and enabling high-concurrency attacks. + +### 2. TOCTOU Race Condition in `/work` - [CWE-367: Time-of-Check Time-of-Use (TOCTOU) Race Condition](https://cwe.mitre.org/data/definitions/367.html) + +The `/work` endpoint reads the current stud count from Redis at the beginning of the request and only increments it after a successful (and slow) signature verification. + +```python +studs = r.get(uid) # (1) Read current count +payload = str(studs) + '|' + uid +# ... slow uov.verify(payload, sig) ... # (2) Large window (~2.5s) +if verified: + studs = r.incr(uid) # (3) Increment count +``` + +Because verification takes several seconds, multiple concurrent requests can all read the same `studs` value (e.g., `2`), verify their respective signatures for the same payload (`"2|uid"`), and then all trigger the increment. This allows jumping from 2 studs to 7 studs in a single race window. + +### 3. UOV Frobenius Signature Cloning - [CWE-327: Use of a Broken or Risky Cryptographic Algorithm](https://cwe.mitre.org/data/definitions/327.html) + +**Verification equation.** The UOV public key consists of $m = 57$ symmetric matrices $P_1, \ldots, P_m \in \mathbb{F}_{2^7}^{n \times n}$ (where $n = 254$). To verify a signature $\mathbf{x} \in \mathbb{F}_{2^7}^n$ against a message, the verifier checks: + +$$ +\mathbf{x}^\top P_i \, \mathbf{x} = t_i \quad \text{for all } i = 1, \ldots, m +$$ + +where each target $t_i \in \{0, 1\}$ is a bit extracted from the message hash (via SHAKE-128). + +**The weak parameterization.** In this implementation, the public key matrices are constructed such that all entries $(P_i)_{jk} \in \mathbb{F}_2 \subseteq \mathbb{F}_{2^7}$ — i.e., every coefficient is either 0 or 1. This is the root cause of the vulnerability. + +**The Frobenius automorphism.** The map $\sigma : \mathbb{F}_{2^7} \to \mathbb{F}_{2^7}$ defined by $\sigma(a) = a^2$ is a field automorphism (the Frobenius). Being a ring homomorphism, it satisfies $\sigma(a + b) = \sigma(a) + \sigma(b)$ and $\sigma(ab) = \sigma(a)\sigma(b)$. Applied componentwise to a vector, $\sigma(\mathbf{x})_j = x_j^2$. + +**Why $\sigma(\mathbf{x})$ is also a valid signature.** Expanding the quadratic form for the cloned vector $\sigma(\mathbf{x})$: + +$$ +\sigma(\mathbf{x})^\top P_i \, \sigma(\mathbf{x}) = \sum_{j,k} x_j^2 \cdot (P_i)_{jk} \cdot x_k^2 +$$ + +Since $(P_i)_{jk} \in \mathbb{F}_2$, it is fixed by $\sigma$, so $(P_i)_{jk}^2 = (P_i)_{jk}$. Using multiplicativity of $\sigma$: + +$$ += \sum_{j,k} \sigma\!\left(x_j \cdot (P_i)_{jk} \cdot x_k\right) = \sigma\!\left(\sum_{j,k} x_j \cdot (P_i)_{jk} \cdot x_k\right) = \sigma\!\left(\mathbf{x}^\top P_i \, \mathbf{x}\right) = \sigma(t_i) = t_i +$$ + +The last step holds because $t_i \in \mathbb{F}_2$ is also fixed by $\sigma$. Therefore $\sigma(\mathbf{x})$ satisfies all $m$ verification equations for the same message. + +**The orbit.** The Frobenius generates the Galois group $\text{Gal}(\mathbb{F}_{2^7}/\mathbb{F}_2) \cong \mathbb{Z}/7\mathbb{Z}$, so $\sigma^7 = \text{id}$. For a generic signature $\mathbf{x}$ (one whose components are not all in $\mathbb{F}_2$), the orbit $\{\mathbf{x},\, \sigma(\mathbf{x}),\, \sigma^2(\mathbf{x}),\, \ldots,\, \sigma^6(\mathbf{x})\}$ has exactly 7 distinct elements, yielding **6 additional valid signatures** from a single observed one. + +## Exploitation + +The exploit is implemented in `solve.py` and follows these steps: + +1. **Preparation**: Obtain `sig_2` — the server-issued signature for `"2|uid"` — by advancing from 0 to 2 studs. The unique `?x=` query parameters bypass HAProxy's per-URL rate limit on each request. + +```python +def buy(): + return re.search(r"signature: ([0-9a-f]+)", requests.get(f"{BASE_URL}/buy", params={"uid": uid, "x": time.time()}).text).group(1) + +def work(sig): + return re.search(r"stud is ([0-9a-f]+)", requests.post(f"{BASE_URL}/work?x={time.time()}", json={"uid": uid, "sig": sig}).text).group(1) + +sig_2 = work(work(buy())) +``` + +2. **Signature Cloning**: Compute 6 Frobenius variants of `sig_2` by applying $\sigma$ componentwise — squaring each byte of the signature in $\mathbb{F}_{2^7}$. + +`gf2_7_square` computes $a^2 \bmod (x^7 + x + 1)$ for a single field element $a$, represented as a 7-bit integer. It uses the standard shift-and-accumulate method for polynomial multiplication in $\mathbb{F}_2[x]$: it iterates over the 7 bits of $a$ (treating it as the multiplier), and for each set bit XORs the current shifted value of $a$ into the accumulator `p`. After each bit, $a$ is shifted left by one (equivalent to multiplying by $x$) and reduced modulo $x^7 + x + 1$ if the degree-7 term would overflow — the `hi` bit detects this overflow and the `^= 0x03` applies the reduction $x^7 \equiv x + 1$, i.e., XORs the low two bits. + +```python +def gf2_7_square(a: int) -> int: + p, b = 0, a + for _ in range(7): + if b & 1: p ^= a # if current bit of b is set, accumulate current a into p + b >>= 1 # advance to next bit + hi = a >> 6 # detect if next shift will overflow 7 bits + a = (a << 1) & 0x7F # shift a left (multiply by x), mask to 7 bits + if hi: a ^= 0x03 # reduce: x^7 ≡ x + 1, so XOR 0b0000011 + return p +``` + +`frob` applies `gf2_7_square` to every byte of the signature `n` times in succession, collecting each intermediate result. `variants[0]` is $\sigma(\mathbf{x})$, `variants[1]` is $\sigma^2(\mathbf{x})$, and so on up to `variants[6]` = $\sigma^7(\mathbf{x}) = \mathbf{x}$. + +```python +def frob(sig_hex: str, n: int) -> list[str]: + variants, cur = [], bytes.fromhex(sig_hex) + for _ in range(n): + cur = bytes(gf2_7_square(b) for b in cur) # apply σ to every byte + variants.append(cur.hex()) + return variants[1:] # skip σ¹, return σ² … σ⁷ (= σ⁰ = original) + +clones = frob(sig_2, 7) # 6 Frobenius clones: σ²(sig_2) … σ⁷(sig_2) +``` + +3. **The Race**: Submit all 6 cloned signatures concurrently. Each request uses a unique `?x=` parameter to bypass HAProxy. Because the clones are not cached, every request enters the slow verification path (~2.5 s). The `b'-'` placeholder in Redis only blocks re-submission of the *same* signature; since all 6 clones are distinct, they race in parallel without blocking each other. All 6 requests read `studs = 2` from Redis before any verification completes. + +```python +async def race(uid: str, sigs: list[str]): + async def post(i, sig): + async with aiohttp.ClientSession(connector=aiohttp.TCPConnector(force_close=True)) as s: + async with s.post(f"{BASE_URL}/work?x={i}", json={"uid": uid, "sig": sig}, timeout=TIMEOUT) as r: + return await r.text() + await asyncio.gather(*(post(i, sig) for i, sig in enumerate(sigs))) + +asyncio.run(race(uid, clones)) +``` + +4. **Completion**: Once all verifications finish, each successful request calls `r.incr(uid)`, advancing the stud count from 2 to at least 7. The attacker then calls `/buy` to retrieve the flag. + +## Remediation + +1. **Secure Rate Limiting**: Configure HAProxy to track request rates by `path` or `src` (IP address) rather than the full `url` to prevent query-parameter-based bypasses. +2. **Atomic State Updates**: Use atomic Redis operations or Lua scripts to ensure that the "read-verify-increment" cycle is protected against race conditions. For example, use a lock or check that the stud count hasn't changed before incrementing. +3. **Proper UOV Parameterization**: Ensure that the secret linear transformation $T$ (and consequently the public key) is chosen with entries from the full extension field $\mathbb{F}_{2^7}$ rather than being restricted to the subfield $\mathbb{F}_2$. This breaks the Frobenius symmetry. diff --git a/umassctf-2026/crypto/Hens and Roosters/backend/Dockerfile b/umassctf-2026/crypto/Hens and Roosters/backend/Dockerfile new file mode 100644 index 00000000..25f39850 --- /dev/null +++ b/umassctf-2026/crypto/Hens and Roosters/backend/Dockerfile @@ -0,0 +1,9 @@ +FROM sagemath/sagemath:latest + +USER root + +RUN sage -pip install flask gunicorn redis[hiredis] + +COPY app.py uov.py private_key.sobj public_key.sobj ./ +EXPOSE 8000 +CMD ["sage", "-python", "-m", "gunicorn", "app:app", "-k", "gthread", "--threads", "80", "-w", "1", "--bind", "0.0.0.0:8000"] diff --git a/umassctf-2026/crypto/Hens and Roosters/backend/app.py b/umassctf-2026/crypto/Hens and Roosters/backend/app.py new file mode 100644 index 00000000..1d3f55ac --- /dev/null +++ b/umassctf-2026/crypto/Hens and Roosters/backend/app.py @@ -0,0 +1,95 @@ +import os +import string +import time +from flask import Flask, request +import redis +from uov import UOV + +app = Flask(__name__) +sig_len = 508 +flag = os.environ.get("FLAG", "UMASS{fakeflag}") + +pool = redis.ConnectionPool(host='redis', port=6379, max_connections=50) +r = redis.Redis(connection_pool=pool) + +uov = UOV() + + +@app.get('/buy') +def buy(): + uid = request.args.get('uid') + if not uid: + return f"User not specified!" + uid = str(uid).lower() + studs = r.get(uid) + if studs is None: + return f"User {uid} does not exist in our system!" + studs = int(studs) + payload = str(studs) + '|' + uid + if studs == 0: + free_sig = r.get(payload) + if free_sig is None: + free_sig = uov.sign(payload) + r.set(payload, free_sig, ex=240) + r.set(free_sig, payload, ex=240) + else: + free_sig = free_sig.decode() + return f"You don't even have any studs? Save up seven studs for a lego set! Here's a free signature: {free_sig}" + elif studs == 1: + return "Only 1 stud? Save up 7 studs for a lego set!" + elif studs < 7: + return f"Only {studs} studs? Save up 7 studs for a lego set!" + else: + r.delete(uid) + return f"You have 6- wait, no, 7 studs! Here's your lego set: {flag}" + + +@app.get('/') +def index(): + uid = os.urandom(8).hex().lower() + r.set(uid, 0, ex=240) + return f"Your randomly generated uid is {uid}!" + + +@app.post('/work') +def work(): + request_body = request.get_json() + uid = str(request_body["uid"]).lower() + sig = str(request_body["sig"]) + studs = r.get(uid) + if studs is None: + return f"User {uid} does not exist in our system!" + studs = int(studs) + payload = str(studs) + '|' + uid + if len(sig) != sig_len: + return "Incorrect signature length!" + if not all(c in string.hexdigits for c in sig): + return "Incorrect signature format!" + sig_bytes = bytes.fromhex(sig) + if not all(0 <= sig_byte < 128 for sig_byte in sig_bytes): + return "Incorrect signature bytes!" + value = r.get(str(sig)) + if value is None: + r.set(sig, b'-', ex=240) + verified = uov.verify(payload, sig_bytes) + if verified: + r.set(sig, payload, ex=240) + elif value == b'-': + return "The signature is still being processed, please send a request later!" + else: + verified = value.decode() == payload + + if verified: + studs = r.incr(uid) + if studs > 2: + return "You're not getting any more free studs!" + else: + new_sig = uov.sign(str(studs) + '|' + uid) + r.set(new_sig, str(studs) + '|' + uid, ex=240) + return f"Your next free stud is {new_sig}!" + else: + return f"No free studs for faked keys!" + + +if __name__ == '__main__': + app.run() diff --git a/umassctf-2026/crypto/Hens and Roosters/backend/public_key.sobj b/umassctf-2026/crypto/Hens and Roosters/backend/public_key.sobj new file mode 100644 index 00000000..3df20dad Binary files /dev/null and b/umassctf-2026/crypto/Hens and Roosters/backend/public_key.sobj differ diff --git a/umassctf-2026/crypto/Hens and Roosters/backend/uov.py b/umassctf-2026/crypto/Hens and Roosters/backend/uov.py new file mode 100644 index 00000000..df5e4c05 --- /dev/null +++ b/umassctf-2026/crypto/Hens and Roosters/backend/uov.py @@ -0,0 +1,36 @@ +from sage.all import * +import hashlib +class UOV: + def __init__(self): + self.f = GF(2 ** 7) + self.pk = load('public_key') + self.sk = load('private_key') + m = 57 + v = 197 + n = m + v + self.m = m + self.v = v + self.n = n + + def sign(self, msg): + field = self.f + m = self.m + v = self.v + F, T = self.sk + t = vector(field, ZZ([x for x in hashlib.shake_128(msg.encode()).digest(m)], 256).digits(2)[:m]) + while True: + V = random_vector(field, v) + A = Matrix(self.f, [ov * V for _, ov in F]) + if A.rank() == m: + break + b = vector(self.f, [V * vv * V for vv, _ in F]) + O = A.solve_right(t - b) + signature = ~T * vector(list(V) + list(O)) + return bytes([e.to_integer() for e in signature]).hex() + + def verify(self, msg, sig): + m = self.m + t = ZZ([x for x in hashlib.shake_128(msg.encode()).digest(m)], 256).digits(2)[:m] + sig = vector(self.f, [self.f.from_integer(e) for e in sig]) + result = [sig * f * sig for f in self.pk] + return t == result diff --git a/umassctf-2026/crypto/Hens and Roosters/compose.yaml b/umassctf-2026/crypto/Hens and Roosters/compose.yaml new file mode 100644 index 00000000..94dba1ae --- /dev/null +++ b/umassctf-2026/crypto/Hens and Roosters/compose.yaml @@ -0,0 +1,25 @@ +services: + backend: + build: ./backend + environment: + - FLAG=${FLAG:-UMASS{fakeflag}} + expose: + - "8000" + + proxy: + build: ./proxy + ports: + - "80:80" + depends_on: + - backend + redis: + image: redis:latest + container_name: redis_cache + restart: always + expose: + - "6379" + volumes: + - redis_data:/data + +volumes: + redis_data: diff --git a/umassctf-2026/crypto/Hens and Roosters/proxy/Dockerfile b/umassctf-2026/crypto/Hens and Roosters/proxy/Dockerfile new file mode 100644 index 00000000..580cd225 --- /dev/null +++ b/umassctf-2026/crypto/Hens and Roosters/proxy/Dockerfile @@ -0,0 +1,5 @@ +FROM haproxy:2.9-alpine + +COPY haproxy.cfg /usr/local/etc/haproxy/haproxy.cfg + +EXPOSE 80 diff --git a/umassctf-2026/crypto/Hens and Roosters/proxy/haproxy.cfg b/umassctf-2026/crypto/Hens and Roosters/proxy/haproxy.cfg new file mode 100644 index 00000000..cb889a14 --- /dev/null +++ b/umassctf-2026/crypto/Hens and Roosters/proxy/haproxy.cfg @@ -0,0 +1,22 @@ +global + log stdout format raw local0 + +defaults + mode http + log global + option httplog + timeout connect 5s + timeout client 60s + timeout server 60s + +frontend http_front + bind *:80 + + 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 } + + default_backend servers + +backend servers + server backend backend:8000 diff --git a/umassctf-2026/crypto/Hens and Roosters/solve.py b/umassctf-2026/crypto/Hens and Roosters/solve.py new file mode 100644 index 00000000..26ad2e21 --- /dev/null +++ b/umassctf-2026/crypto/Hens and Roosters/solve.py @@ -0,0 +1,56 @@ +#!/usr/bin/env python3 +import asyncio, re, sys, time +import requests, aiohttp + +BASE_URL = sys.argv[1].rstrip("/") if len(sys.argv) > 1 else "http://34.71.177.222" +TIMEOUT = 90 + +def gf2_7_square(a: int) -> int: + """Computes a^2 in GF(2^7)""" + p, b = 0, a + for _ in range(7): + if b & 1: p ^= a + b >>= 1 + hi = a >> 6 + a = (a << 1) & 0x7F + if hi: a ^= 0x03 + return p + +def frob(sig_hex: str, n: int) -> list[str]: + """Frobenius map of a signature in GF(2^7).""" + variants, cur = [], bytes.fromhex(sig_hex) + for _ in range(n): + cur = bytes(gf2_7_square(b) for b in cur) + variants.append(cur.hex()) + return variants[1:] + +async def race(uid: str, sigs: list[str]): + async def post(i, sig): + async with aiohttp.ClientSession(connector=aiohttp.TCPConnector(force_close=True)) as s: + async with s.post(f"{BASE_URL}/work?x={i}", json={"uid": uid, "sig": sig}, timeout=TIMEOUT) as r: + return await r.text() + await asyncio.gather(*(post(i, sig) for i, sig in enumerate(sigs))) + +def solve(): + uid = re.search(r"uid is ([0-9a-f]+)", requests.get(f"{BASE_URL}/?x={time.time()}").text).group(1) + print(f"UID: {uid}") + + def buy(): + return re.search(r"signature: ([0-9a-f]+)", requests.get(f"{BASE_URL}/buy", params={"uid": uid, "x": time.time()}).text).group(1) + + def work(sig): + return re.search(r"stud is ([0-9a-f]+)", requests.post(f"{BASE_URL}/work?x={time.time()}", json={"uid": uid, "sig": sig}).text).group(1) + + sig_2 = work(work(buy())) + print(f"Got sig_2: {sig_2}") + + clones = frob(sig_2, 7) + print(f"Racing TOCTOU with {len(clones)} Frobenius clones...") + asyncio.run(race(uid, clones)) + + resp = requests.get(f"{BASE_URL}/buy", params={"uid": uid, "x": "flag"}).text + match = re.search(r"UMASS\{[^}]+\}", resp) + print(f"Flag: {match.group()}" if match else f"Failed. Server response:\n{resp}") + +if __name__ == "__main__": + solve()