-
Notifications
You must be signed in to change notification settings - Fork 14
Hens and Roosters crypto/web chall from UMassCTF 2026 #81
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
FlyN-Nick
wants to merge
6
commits into
CUCTF:main
Choose a base branch
from
FlyN-Nick:umassctf/HensandRoosters
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
6 commits
Select commit
Hold shift + click to select a range
fedbea1
Hens and Roosters chall files
FlyN-Nick edd7eba
chall description
FlyN-Nick c653b42
successful solver
FlyN-Nick b4b0ba0
initial chall writeup
FlyN-Nick 2643a25
polished solve script
FlyN-Nick 9c8846e
polished writeup
FlyN-Nick File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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. | ||
|
|
||
| **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. | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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"] |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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: |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
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