Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
172 changes: 172 additions & 0 deletions umassctf-2026/crypto/Hens and Roosters/README.md
Original file line number Diff line number Diff line change
@@ -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.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Explain this a bit, why does this hold. Maybe point to the code


**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.
9 changes: 9 additions & 0 deletions umassctf-2026/crypto/Hens and Roosters/backend/Dockerfile
Original file line number Diff line number Diff line change
@@ -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"]
95 changes: 95 additions & 0 deletions umassctf-2026/crypto/Hens and Roosters/backend/app.py
Original file line number Diff line number Diff line change
@@ -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()
Binary file not shown.
36 changes: 36 additions & 0 deletions umassctf-2026/crypto/Hens and Roosters/backend/uov.py
Original file line number Diff line number Diff line change
@@ -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
25 changes: 25 additions & 0 deletions umassctf-2026/crypto/Hens and Roosters/compose.yaml
Original file line number Diff line number Diff line change
@@ -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:
5 changes: 5 additions & 0 deletions umassctf-2026/crypto/Hens and Roosters/proxy/Dockerfile
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
FROM haproxy:2.9-alpine

COPY haproxy.cfg /usr/local/etc/haproxy/haproxy.cfg

EXPOSE 80
22 changes: 22 additions & 0 deletions umassctf-2026/crypto/Hens and Roosters/proxy/haproxy.cfg
Original file line number Diff line number Diff line change
@@ -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
Loading