Search
Find a vulnerability
Search criteria
406 vulnerabilities
CVE-2026-124279 (GCVE-0-2026-124279)
Vulnerability from cvelistv5 – Published: 2026-11-13 16:18 – Updated: 2026-11-13 19:32
VLAI
EPSS
VEX
Title
Streaming request body limit enforced only after an 8 MB read in elixir-grpc grpc_server
Summary
Allocation of Resources Without Limits or Throttling vulnerability in elixir-grpc grpc_server allows unauthenticated attackers to exhaust server memory via client-streaming or bidirectional-streaming requests.
The streaming read clause of GRPC.Server.Adapters.Cowboy.Handler.info/3 (grpc_server/lib/grpc/server/adapters/cowboy/handler.ex) asks Cowboy for up to 8,000,000 bytes per read and compares the running total with max_body_size only after the read returns. The gRPC length prefix of a message is not checked before the message is buffered. Each stream can therefore buffer up to one full read beyond the configured limit before it is refused, and many concurrent streams exhaust node memory. Releases before 1.0.6 apply no limit to streaming requests at all.
This issue affects grpc_server: from 1.0.0 before 1.1.0.
Severity
SSVC
Exploitation: none
Automatable: no
Technical Impact: partial
CISA Coordinator · CISA-ADP (v2.0.3)
Decision recorded 2026-11-13 17:55 UTC
CWE
- CWE-770 - Allocation of Resources Without Limits or Throttling
Assigner
References
5 references
| URL | Tags |
|---|---|
| https://github.com/elixir-grpc/grpc/security/advi… | vendor-advisoryrelated |
| https://cna.erlef.org/cves/CVE-2026-124279.html | related |
| https://osv.dev/vulnerability/EEF-CVE-2026-124279 | related |
| https://github.com/elixir-grpc/grpc/commit/49e18c… | related |
| https://github.com/elixir-grpc/grpc/commit/b1e771… | patch |
Impacted products
2 products
| Vendor | Product | Version | |
|---|---|---|---|
| elixir-grpc | grpc_server |
Affected:
1.0.0 , < 1.1.0
(semver)
cpe:2.3:a:elixir-grpc:grpc_server:*:*:*:*:*:*:*:* |
|
| elixir-grpc | grpc_server |
Affected:
49e18c3ec6bb9afe2f712caad3dbab5c56a68a00 , < b1e771d558941b1ed4b6de8b39f6a5375543db01
(git)
cpe:2.3:a:elixir-grpc:grpc_server:*:*:*:*:*:*:*:* |
{
"containers": {
"adp": [
{
"metrics": [
{
"other": {
"content": {
"id": "CVE-2026-124279",
"options": [
{
"Exploitation": "none"
},
{
"Automatable": "no"
},
{
"Technical Impact": "partial"
}
],
"role": "CISA Coordinator",
"timestamp": "2026-11-13T17:55:00.516542Z",
"version": "2.0.3"
},
"type": "ssvc"
}
}
],
"providerMetadata": {
"dateUpdated": "2026-11-13T19:32:24.719Z",
"orgId": "134c704f-9b21-4f2e-91b3-4a467353bcc0",
"shortName": "CISA-ADP"
},
"title": "CISA ADP Vulnrichment"
}
],
"cna": {
"affected": [
{
"collectionURL": "https://repo.hex.pm",
"cpes": [
"cpe:2.3:a:elixir-grpc:grpc_server:*:*:*:*:*:*:*:*"
],
"defaultStatus": "unaffected",
"modules": [
"\u0027Elixir.GRPC.Server.Adapters.Cowboy.Handler\u0027",
"\u0027Elixir.GRPC.Server.Adapters.Cowboy\u0027"
],
"packageName": "grpc_server",
"packageURL": "pkg:hex/grpc_server",
"product": "grpc_server",
"programFiles": [
"lib/grpc/server/adapters/cowboy/handler.ex",
"lib/grpc/server/adapters/cowboy.ex"
],
"programRoutines": [
{
"name": "\u0027Elixir.GRPC.Server.Adapters.Cowboy.Handler\u0027:info/3"
},
{
"name": "\u0027Elixir.GRPC.Server.Adapters.Cowboy\u0027:reading_stream/1"
}
],
"repo": "https://github.com/elixir-grpc/grpc",
"vendor": "elixir-grpc",
"versions": [
{
"lessThan": "1.1.0",
"status": "affected",
"version": "1.0.0",
"versionType": "semver"
}
]
},
{
"collectionURL": "https://github.com",
"cpes": [
"cpe:2.3:a:elixir-grpc:grpc_server:*:*:*:*:*:*:*:*"
],
"defaultStatus": "unaffected",
"modules": [
"\u0027Elixir.GRPC.Server.Adapters.Cowboy.Handler\u0027",
"\u0027Elixir.GRPC.Server.Adapters.Cowboy\u0027"
],
"packageName": "elixir-grpc/grpc",
"packageURL": "pkg:github/elixir-grpc/grpc",
"product": "grpc_server",
"programFiles": [
"grpc_server/lib/grpc/server/adapters/cowboy/handler.ex",
"grpc_server/lib/grpc/server/adapters/cowboy.ex"
],
"programRoutines": [
{
"name": "\u0027Elixir.GRPC.Server.Adapters.Cowboy.Handler\u0027:info/3"
},
{
"name": "\u0027Elixir.GRPC.Server.Adapters.Cowboy\u0027:reading_stream/1"
}
],
"repo": "https://github.com/elixir-grpc/grpc",
"vendor": "elixir-grpc",
"versions": [
{
"lessThan": "b1e771d558941b1ed4b6de8b39f6a5375543db01",
"status": "affected",
"version": "49e18c3ec6bb9afe2f712caad3dbab5c56a68a00",
"versionType": "git"
}
]
}
],
"configurations": [
{
"lang": "en",
"supportingMedia": [
{
"base64": false,
"type": "text/html",
"value": "\u003cp\u003eThe server must expose at least one client-streaming or bidirectional-streaming RPC method.\u003c/p\u003e"
},
{
"base64": false,
"type": "text/markdown",
"value": "The server must expose at least one client-streaming or bidirectional-streaming RPC method."
}
],
"value": "The server must expose at least one client-streaming or bidirectional-streaming RPC method."
}
],
"cpeApplicability": [
{
"nodes": [
{
"cpeMatch": [
{
"criteria": "cpe:2.3:a:elixir-grpc:grpc_server:*:*:*:*:*:*:*:*",
"versionEndExcluding": "1.1.0",
"versionStartIncluding": "1.0.0",
"vulnerable": true
}
],
"negate": false,
"operator": "OR"
}
],
"operator": "AND"
}
],
"credits": [
{
"lang": "en",
"type": "reporter",
"value": "Alfie Goh Li Heng"
},
{
"lang": "en",
"type": "finder",
"value": "Alfie Goh Li Heng"
},
{
"lang": "en",
"type": "remediation developer",
"value": "Paulo Valente"
},
{
"lang": "en",
"type": "coordinator",
"value": "Peter Ullrich"
},
{
"lang": "en",
"type": "coordinator",
"value": "Jonatan M\u00e4nnchen / EEF"
}
],
"dateAssigned": "2026-11-13T11:19:13.000Z",
"descriptions": [
{
"lang": "en",
"supportingMedia": [
{
"base64": false,
"type": "text/html",
"value": "\u003cp\u003eAllocation of Resources Without Limits or Throttling vulnerability in elixir-grpc grpc_server allows unauthenticated attackers to exhaust server memory via client-streaming or bidirectional-streaming requests.\u003c/p\u003e\n\u003cp\u003eThe streaming read clause of \u003ccode\u003eGRPC.Server.Adapters.Cowboy.Handler.info/3\u003c/code\u003e (\u003ccode\u003egrpc_server/lib/grpc/server/adapters/cowboy/handler.ex\u003c/code\u003e) asks Cowboy for up to 8,000,000 bytes per read and compares the running total with \u003ccode\u003emax_body_size\u003c/code\u003e only after the read returns. The gRPC length prefix of a message is not checked before the message is buffered. Each stream can therefore buffer up to one full read beyond the configured limit before it is refused, and many concurrent streams exhaust node memory. Releases before 1.0.6 apply no limit to streaming requests at all.\u003c/p\u003e\n\u003cp\u003eThis issue affects grpc_server: from 1.0.0 before 1.1.0.\u003c/p\u003e"
},
{
"base64": false,
"type": "text/markdown",
"value": "Allocation of Resources Without Limits or Throttling vulnerability in elixir-grpc grpc_server allows unauthenticated attackers to exhaust server memory via client-streaming or bidirectional-streaming requests.\n\nThe streaming read clause of `GRPC.Server.Adapters.Cowboy.Handler.info/3` (`grpc_server/lib/grpc/server/adapters/cowboy/handler.ex`) asks Cowboy for up to 8,000,000 bytes per read and compares the running total with `max_body_size` only after the read returns. The gRPC length prefix of a message is not checked before the message is buffered. Each stream can therefore buffer up to one full read beyond the configured limit before it is refused, and many concurrent streams exhaust node memory. Releases before 1.0.6 apply no limit to streaming requests at all.\n\nThis issue affects grpc_server: from 1.0.0 before 1.1.0."
}
],
"value": "Allocation of Resources Without Limits or Throttling vulnerability in elixir-grpc grpc_server allows unauthenticated attackers to exhaust server memory via client-streaming or bidirectional-streaming requests.\n\nThe streaming read clause of GRPC.Server.Adapters.Cowboy.Handler.info/3 (grpc_server/lib/grpc/server/adapters/cowboy/handler.ex) asks Cowboy for up to 8,000,000 bytes per read and compares the running total with max_body_size only after the read returns. The gRPC length prefix of a message is not checked before the message is buffered. Each stream can therefore buffer up to one full read beyond the configured limit before it is refused, and many concurrent streams exhaust node memory. Releases before 1.0.6 apply no limit to streaming requests at all.\n\nThis issue affects grpc_server: from 1.0.0 before 1.1.0."
}
],
"impacts": [
{
"capecId": "CAPEC-130",
"descriptions": [
{
"lang": "en",
"supportingMedia": [
{
"base64": false,
"type": "text/html",
"value": "\u003cp\u003eAn unauthenticated client that opens many streaming calls can make the server buffer more request data than its configured limit allows and crash the node, taking down every service on it.\u003c/p\u003e"
},
{
"base64": false,
"type": "text/markdown",
"value": "An unauthenticated client that opens many streaming calls can make the server buffer more request data than its configured limit allows and crash the node, taking down every service on it."
}
],
"value": "An unauthenticated client that opens many streaming calls can make the server buffer more request data than its configured limit allows and crash the node, taking down every service on it."
}
]
}
],
"metrics": [
{
"cvssV4_0": {
"Automatable": "NOT_DEFINED",
"Recovery": "NOT_DEFINED",
"Safety": "NOT_DEFINED",
"attackComplexity": "LOW",
"attackRequirements": "PRESENT",
"attackVector": "NETWORK",
"baseScore": 8.2,
"baseSeverity": "HIGH",
"privilegesRequired": "NONE",
"providerUrgency": "NOT_DEFINED",
"subAvailabilityImpact": "NONE",
"subConfidentialityImpact": "NONE",
"subIntegrityImpact": "NONE",
"userInteraction": "NONE",
"valueDensity": "NOT_DEFINED",
"vectorString": "CVSS:4.0/AV:N/AC:L/AT:P/PR:N/UI:N/VC:N/VI:N/VA:H/SC:N/SI:N/SA:N",
"version": "4.0",
"vulnAvailabilityImpact": "HIGH",
"vulnConfidentialityImpact": "NONE",
"vulnIntegrityImpact": "NONE",
"vulnerabilityResponseEffort": "NOT_DEFINED"
},
"format": "CVSS",
"scenarios": [
{
"lang": "en",
"value": "GENERAL"
}
]
}
],
"problemTypes": [
{
"descriptions": [
{
"cweId": "CWE-770",
"description": "CWE-770 Allocation of Resources Without Limits or Throttling",
"lang": "en",
"type": "CWE"
}
]
}
],
"providerMetadata": {
"dateUpdated": "2026-11-13T16:18:56.700Z",
"orgId": "6b3ad84c-e1a6-4bf7-a703-f496b71e49db",
"shortName": "EEF"
},
"references": [
{
"name": "Streaming request body limit enforced only after an 8 MB read in elixir-grpc grpc_server",
"tags": [
"vendor-advisory",
"related"
],
"url": "https://github.com/elixir-grpc/grpc/security/advisories/GHSA-736x-r87j-jhcm"
},
{
"name": "EEF CNA record for CVE-2026-124279",
"tags": [
"related"
],
"url": "https://cna.erlef.org/cves/CVE-2026-124279.html"
},
{
"name": "OSV record EEF-CVE-2026-124279",
"tags": [
"related"
],
"url": "https://osv.dev/vulnerability/EEF-CVE-2026-124279"
},
{
"name": "Introducing commit 49e18c3 in elixir-grpc/grpc",
"tags": [
"related"
],
"url": "https://github.com/elixir-grpc/grpc/commit/49e18c3ec6bb9afe2f712caad3dbab5c56a68a00"
},
{
"name": "Fix commit b1e771d in elixir-grpc/grpc",
"tags": [
"patch"
],
"url": "https://github.com/elixir-grpc/grpc/commit/b1e771d558941b1ed4b6de8b39f6a5375543db01"
}
],
"source": {
"discovery": "EXTERNAL"
},
"title": "Streaming request body limit enforced only after an 8 MB read in elixir-grpc grpc_server",
"x_technicalAnalysis": [
{
"lang": "en",
"supportingMedia": [
{
"base64": false,
"type": "text/html",
"value": "\u003cp\u003e\u003cstrong\u003e1. Large reads\u003c/strong\u003e\u003c/p\u003e\n\u003cp\u003e\u003ccode\u003easync_read_body/2\u003c/code\u003e asks Cowboy for up to 8,000,000 bytes per read on the streaming path, whatever \u003ccode\u003emax_body_size\u003c/code\u003e is set to.\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003e2. The limit is checked after the read\u003c/strong\u003e\u003c/p\u003e\n\u003cp\u003e\u003ccode\u003etrack_body_bytes/2\u003c/code\u003e adds each returned chunk to a running total and refuses the stream with \u003ccode\u003eRESOURCE_EXHAUSTED\u003c/code\u003e only once the total exceeds \u003ccode\u003emax_body_size\u003c/code\u003e. By then the whole chunk is in memory.\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003e3. No check on the length prefix\u003c/strong\u003e\u003c/p\u003e\n\u003cp\u003eThe 5-byte gRPC prefix can declare up to 4 GiB. The server does not compare it with any limit before buffering, so a prefix that declares an oversized message does not stop the read.\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003e4. Result\u003c/strong\u003e\u003c/p\u003e\n\u003cp\u003eEach client-streaming or bidirectional-streaming call can make the server hold several megabytes more than the configured limit, and the attacker can open many such calls at once. Before 1.0.6 the streaming path had no limit, and \u003ccode\u003eGRPC.Server.Adapters.Cowboy.reading_stream/1\u003c/code\u003e buffered the whole body.\u003c/p\u003e"
},
{
"base64": false,
"type": "text/markdown",
"value": "**1. Large reads**\n\n`async_read_body/2` asks Cowboy for up to 8,000,000 bytes per read on the streaming path, whatever `max_body_size` is set to.\n\n**2. The limit is checked after the read**\n\n`track_body_bytes/2` adds each returned chunk to a running total and refuses the stream with `RESOURCE_EXHAUSTED` only once the total exceeds `max_body_size`. By then the whole chunk is in memory.\n\n**3. No check on the length prefix**\n\nThe 5-byte gRPC prefix can declare up to 4 GiB. The server does not compare it with any limit before buffering, so a prefix that declares an oversized message does not stop the read.\n\n**4. Result**\n\nEach client-streaming or bidirectional-streaming call can make the server hold several megabytes more than the configured limit, and the attacker can open many such calls at once. Before 1.0.6 the streaming path had no limit, and `GRPC.Server.Adapters.Cowboy.reading_stream/1` buffered the whole body."
}
],
"value": "1. Large reads\n\nasync_read_body/2 asks Cowboy for up to 8,000,000 bytes per read on the streaming path, whatever max_body_size is set to.\n\n2. The limit is checked after the read\n\ntrack_body_bytes/2 adds each returned chunk to a running total and refuses the stream with RESOURCE_EXHAUSTED only once the total exceeds max_body_size. By then the whole chunk is in memory.\n\n3. No check on the length prefix\n\nThe 5-byte gRPC prefix can declare up to 4 GiB. The server does not compare it with any limit before buffering, so a prefix that declares an oversized message does not stop the read.\n\n4. Result\n\nEach client-streaming or bidirectional-streaming call can make the server hold several megabytes more than the configured limit, and the attacker can open many such calls at once. Before 1.0.6 the streaming path had no limit, and GRPC.Server.Adapters.Cowboy.reading_stream/1 buffered the whole body."
}
]
}
},
"cveMetadata": {
"assignerOrgId": "6b3ad84c-e1a6-4bf7-a703-f496b71e49db",
"assignerShortName": "EEF",
"cveId": "CVE-2026-124279",
"datePublished": "2026-11-13T16:18:56.700Z",
"dateReserved": "2026-11-13T11:18:43.141Z",
"dateUpdated": "2026-11-13T19:32:24.719Z",
"state": "PUBLISHED"
},
"dataType": "CVE_RECORD",
"dataVersion": "5.2"
}
CVE-2026-121809 (GCVE-0-2026-121809)
Vulnerability from cvelistv5 – Published: 2026-11-13 16:18 – Updated: 2026-11-13 19:32
VLAI
EPSS
VEX
Title
CPU exhaustion via a truncated gRPC message on streaming RPCs in elixir-grpc grpc_server
Summary
Loop with Unreachable Exit Condition ('Infinite Loop') vulnerability in elixir-grpc grpc_server and grpc allows unauthenticated attackers to occupy a BEAM scheduler indefinitely via a client-streaming or bidirectional-streaming request whose body ends inside a gRPC message.
GRPC.Server.Adapters.Cowboy.read_stream/1 (grpc_server/lib/grpc/server/adapters/cowboy.ex, and the Cowboy adapter in grpc before the package split) asks for more data whenever the buffer does not hold a complete message. Once the request body has ended, every further read returns an empty chunk, so the buffer never changes and the function retries without end. A single 5-byte length prefix sent with END_STREAM keeps one scheduler busy for as long as the connection stays open, and repeated requests exhaust all schedulers.
This issue affects grpc_server: from 1.0.0 before 1.1.0; grpc: from 0.3.1 before 1.0.0.
Severity
SSVC
Exploitation: none
Automatable: no
Technical Impact: partial
CISA Coordinator · CISA-ADP (v2.0.3)
Decision recorded 2026-11-13 17:55 UTC
Assigner
References
6 references
| URL | Tags |
|---|---|
| https://github.com/elixir-grpc/grpc/security/advi… | vendor-advisoryrelated |
| https://cna.erlef.org/cves/CVE-2026-121809.html | related |
| https://osv.dev/vulnerability/EEF-CVE-2026-121809 | related |
| https://github.com/elixir-grpc/grpc/commit/0332fa… | related |
| https://github.com/elixir-grpc/grpc/commit/38955f… | patch |
| https://github.com/elixir-grpc/grpc/commit/6d8a09… | related |
Impacted products
4 products
| Vendor | Product | Version | |
|---|---|---|---|
| elixir-grpc | grpc_server |
Affected:
1.0.0 , < 1.1.0
(semver)
cpe:2.3:a:elixir-grpc:grpc_server:*:*:*:*:*:*:*:* |
|
| elixir-grpc | grpc_server |
Affected:
0332faae5f5e132314794dbfdae5edecbccea292 , < 38955f1392cbf08eef231a347348445410455c85
(git)
cpe:2.3:a:elixir-grpc:grpc_server:*:*:*:*:*:*:*:* |
|
| elixir-grpc | grpc |
Affected:
0.3.1 , < 1.0.0
(semver)
cpe:2.3:a:elixir-grpc:grpc:*:*:*:*:*:*:*:* |
|
| elixir-grpc | grpc |
Affected:
6d8a09ad919e208fb600538ac8b19bd733a04310 , < 0332faae5f5e132314794dbfdae5edecbccea292
(git)
cpe:2.3:a:elixir-grpc:grpc:*:*:*:*:*:*:*:* |
{
"containers": {
"adp": [
{
"metrics": [
{
"other": {
"content": {
"id": "CVE-2026-121809",
"options": [
{
"Exploitation": "none"
},
{
"Automatable": "no"
},
{
"Technical Impact": "partial"
}
],
"role": "CISA Coordinator",
"timestamp": "2026-11-13T17:55:12.226355Z",
"version": "2.0.3"
},
"type": "ssvc"
}
}
],
"providerMetadata": {
"dateUpdated": "2026-11-13T19:32:40.114Z",
"orgId": "134c704f-9b21-4f2e-91b3-4a467353bcc0",
"shortName": "CISA-ADP"
},
"title": "CISA ADP Vulnrichment"
}
],
"cna": {
"affected": [
{
"collectionURL": "https://repo.hex.pm",
"cpes": [
"cpe:2.3:a:elixir-grpc:grpc_server:*:*:*:*:*:*:*:*"
],
"defaultStatus": "unaffected",
"modules": [
"\u0027Elixir.GRPC.Server.Adapters.Cowboy\u0027"
],
"packageName": "grpc_server",
"packageURL": "pkg:hex/grpc_server",
"product": "grpc_server",
"programFiles": [
"lib/grpc/server/adapters/cowboy.ex"
],
"programRoutines": [
{
"name": "\u0027Elixir.GRPC.Server.Adapters.Cowboy\u0027:reading_stream/1"
},
{
"name": "\u0027Elixir.GRPC.Server.Adapters.Cowboy\u0027:read_stream/1"
}
],
"repo": "https://github.com/elixir-grpc/grpc",
"vendor": "elixir-grpc",
"versions": [
{
"lessThan": "1.1.0",
"status": "affected",
"version": "1.0.0",
"versionType": "semver"
}
]
},
{
"collectionURL": "https://github.com",
"cpes": [
"cpe:2.3:a:elixir-grpc:grpc_server:*:*:*:*:*:*:*:*"
],
"defaultStatus": "unaffected",
"modules": [
"\u0027Elixir.GRPC.Server.Adapters.Cowboy\u0027"
],
"packageName": "elixir-grpc/grpc",
"packageURL": "pkg:github/elixir-grpc/grpc",
"product": "grpc_server",
"programFiles": [
"grpc_server/lib/grpc/server/adapters/cowboy.ex"
],
"programRoutines": [
{
"name": "\u0027Elixir.GRPC.Server.Adapters.Cowboy\u0027:reading_stream/1"
},
{
"name": "\u0027Elixir.GRPC.Server.Adapters.Cowboy\u0027:read_stream/1"
}
],
"repo": "https://github.com/elixir-grpc/grpc",
"vendor": "elixir-grpc",
"versions": [
{
"lessThan": "38955f1392cbf08eef231a347348445410455c85",
"status": "affected",
"version": "0332faae5f5e132314794dbfdae5edecbccea292",
"versionType": "git"
}
]
},
{
"collectionURL": "https://repo.hex.pm",
"cpes": [
"cpe:2.3:a:elixir-grpc:grpc:*:*:*:*:*:*:*:*"
],
"defaultStatus": "unaffected",
"modules": [
"\u0027Elixir.GRPC.Server.Adapters.Cowboy\u0027"
],
"packageName": "grpc",
"packageURL": "pkg:hex/grpc",
"product": "grpc",
"programFiles": [
"lib/grpc/server/adapters/cowboy.ex"
],
"programRoutines": [
{
"name": "\u0027Elixir.GRPC.Server.Adapters.Cowboy\u0027:reading_stream/1"
},
{
"name": "\u0027Elixir.GRPC.Server.Adapters.Cowboy\u0027:read_stream/1"
}
],
"repo": "https://github.com/elixir-grpc/grpc",
"vendor": "elixir-grpc",
"versions": [
{
"lessThan": "1.0.0",
"status": "affected",
"version": "0.3.1",
"versionType": "semver"
}
]
},
{
"collectionURL": "https://github.com",
"cpes": [
"cpe:2.3:a:elixir-grpc:grpc:*:*:*:*:*:*:*:*"
],
"defaultStatus": "unaffected",
"modules": [
"\u0027Elixir.GRPC.Server.Adapters.Cowboy\u0027"
],
"packageName": "elixir-grpc/grpc",
"packageURL": "pkg:github/elixir-grpc/grpc",
"product": "grpc",
"programFiles": [
"lib/grpc/server/adapters/cowboy.ex"
],
"programRoutines": [
{
"name": "\u0027Elixir.GRPC.Server.Adapters.Cowboy\u0027:reading_stream/1"
},
{
"name": "\u0027Elixir.GRPC.Server.Adapters.Cowboy\u0027:read_stream/1"
}
],
"repo": "https://github.com/elixir-grpc/grpc",
"vendor": "elixir-grpc",
"versions": [
{
"lessThan": "0332faae5f5e132314794dbfdae5edecbccea292",
"status": "affected",
"version": "6d8a09ad919e208fb600538ac8b19bd733a04310",
"versionType": "git"
}
]
}
],
"configurations": [
{
"lang": "en",
"supportingMedia": [
{
"base64": false,
"type": "text/html",
"value": "\u003cp\u003eThe server must expose at least one client-streaming or bidirectional-streaming RPC method.\u003c/p\u003e"
},
{
"base64": false,
"type": "text/markdown",
"value": "The server must expose at least one client-streaming or bidirectional-streaming RPC method."
}
],
"value": "The server must expose at least one client-streaming or bidirectional-streaming RPC method."
}
],
"cpeApplicability": [
{
"nodes": [
{
"cpeMatch": [
{
"criteria": "cpe:2.3:a:elixir-grpc:grpc_server:*:*:*:*:*:*:*:*",
"versionEndExcluding": "1.1.0",
"versionStartIncluding": "1.0.0",
"vulnerable": true
},
{
"criteria": "cpe:2.3:a:elixir-grpc:grpc:*:*:*:*:*:*:*:*",
"versionEndExcluding": "1.0.0",
"versionStartIncluding": "0.3.1",
"vulnerable": true
}
],
"negate": false,
"operator": "OR"
}
],
"operator": "AND"
}
],
"credits": [
{
"lang": "en",
"type": "reporter",
"value": "Adam Jordan"
},
{
"lang": "en",
"type": "finder",
"value": "Adam Jordan"
},
{
"lang": "en",
"type": "remediation developer",
"value": "Marko Vejnovic"
},
{
"lang": "en",
"type": "remediation reviewer",
"value": "Paulo Valente"
},
{
"lang": "en",
"type": "coordinator",
"value": "Peter Ullrich"
},
{
"lang": "en",
"type": "coordinator",
"value": "Jonatan M\u00e4nnchen / EEF"
}
],
"dateAssigned": "2026-11-13T11:19:46.000Z",
"descriptions": [
{
"lang": "en",
"supportingMedia": [
{
"base64": false,
"type": "text/html",
"value": "\u003cp\u003eLoop with Unreachable Exit Condition (\u0027Infinite Loop\u0027) vulnerability in elixir-grpc grpc_server and grpc allows unauthenticated attackers to occupy a BEAM scheduler indefinitely via a client-streaming or bidirectional-streaming request whose body ends inside a gRPC message.\u003c/p\u003e\n\u003cp\u003e\u003ccode\u003eGRPC.Server.Adapters.Cowboy.read_stream/1\u003c/code\u003e (\u003ccode\u003egrpc_server/lib/grpc/server/adapters/cowboy.ex\u003c/code\u003e, and the Cowboy adapter in \u003ccode\u003egrpc\u003c/code\u003e before the package split) asks for more data whenever the buffer does not hold a complete message. Once the request body has ended, every further read returns an empty chunk, so the buffer never changes and the function retries without end. A single 5-byte length prefix sent with \u003ccode\u003eEND_STREAM\u003c/code\u003e keeps one scheduler busy for as long as the connection stays open, and repeated requests exhaust all schedulers.\u003c/p\u003e\n\u003cp\u003eThis issue affects grpc_server: from 1.0.0 before 1.1.0; grpc: from 0.3.1 before 1.0.0.\u003c/p\u003e"
},
{
"base64": false,
"type": "text/markdown",
"value": "Loop with Unreachable Exit Condition (\u0027Infinite Loop\u0027) vulnerability in elixir-grpc grpc_server and grpc allows unauthenticated attackers to occupy a BEAM scheduler indefinitely via a client-streaming or bidirectional-streaming request whose body ends inside a gRPC message.\n\n`GRPC.Server.Adapters.Cowboy.read_stream/1` (`grpc_server/lib/grpc/server/adapters/cowboy.ex`, and the Cowboy adapter in `grpc` before the package split) asks for more data whenever the buffer does not hold a complete message. Once the request body has ended, every further read returns an empty chunk, so the buffer never changes and the function retries without end. A single 5-byte length prefix sent with `END_STREAM` keeps one scheduler busy for as long as the connection stays open, and repeated requests exhaust all schedulers.\n\nThis issue affects grpc_server: from 1.0.0 before 1.1.0; grpc: from 0.3.1 before 1.0.0."
}
],
"value": "Loop with Unreachable Exit Condition (\u0027Infinite Loop\u0027) vulnerability in elixir-grpc grpc_server and grpc allows unauthenticated attackers to occupy a BEAM scheduler indefinitely via a client-streaming or bidirectional-streaming request whose body ends inside a gRPC message.\n\nGRPC.Server.Adapters.Cowboy.read_stream/1 (grpc_server/lib/grpc/server/adapters/cowboy.ex, and the Cowboy adapter in grpc before the package split) asks for more data whenever the buffer does not hold a complete message. Once the request body has ended, every further read returns an empty chunk, so the buffer never changes and the function retries without end. A single 5-byte length prefix sent with END_STREAM keeps one scheduler busy for as long as the connection stays open, and repeated requests exhaust all schedulers.\n\nThis issue affects grpc_server: from 1.0.0 before 1.1.0; grpc: from 0.3.1 before 1.0.0."
}
],
"impacts": [
{
"capecId": "CAPEC-130",
"descriptions": [
{
"lang": "en",
"supportingMedia": [
{
"base64": false,
"type": "text/html",
"value": "\u003cp\u003eAn unauthenticated client can keep one scheduler busy per open connection with a 5-byte request, and enough connections deny service to every application on the node.\u003c/p\u003e"
},
{
"base64": false,
"type": "text/markdown",
"value": "An unauthenticated client can keep one scheduler busy per open connection with a 5-byte request, and enough connections deny service to every application on the node."
}
],
"value": "An unauthenticated client can keep one scheduler busy per open connection with a 5-byte request, and enough connections deny service to every application on the node."
}
]
}
],
"metrics": [
{
"cvssV4_0": {
"Automatable": "NOT_DEFINED",
"Recovery": "NOT_DEFINED",
"Safety": "NOT_DEFINED",
"attackComplexity": "LOW",
"attackRequirements": "PRESENT",
"attackVector": "NETWORK",
"baseScore": 8.2,
"baseSeverity": "HIGH",
"privilegesRequired": "NONE",
"providerUrgency": "NOT_DEFINED",
"subAvailabilityImpact": "NONE",
"subConfidentialityImpact": "NONE",
"subIntegrityImpact": "NONE",
"userInteraction": "NONE",
"valueDensity": "NOT_DEFINED",
"vectorString": "CVSS:4.0/AV:N/AC:L/AT:P/PR:N/UI:N/VC:N/VI:N/VA:H/SC:N/SI:N/SA:N",
"version": "4.0",
"vulnAvailabilityImpact": "HIGH",
"vulnConfidentialityImpact": "NONE",
"vulnIntegrityImpact": "NONE",
"vulnerabilityResponseEffort": "NOT_DEFINED"
},
"format": "CVSS",
"scenarios": [
{
"lang": "en",
"value": "GENERAL"
}
]
}
],
"problemTypes": [
{
"descriptions": [
{
"cweId": "CWE-400",
"description": "CWE-400 Uncontrolled Resource Consumption",
"lang": "en",
"type": "CWE"
}
]
},
{
"descriptions": [
{
"cweId": "CWE-835",
"description": "CWE-835 Loop with Unreachable Exit Condition",
"lang": "en",
"type": "CWE"
}
]
}
],
"providerMetadata": {
"dateUpdated": "2026-11-13T16:18:37.954Z",
"orgId": "6b3ad84c-e1a6-4bf7-a703-f496b71e49db",
"shortName": "EEF"
},
"references": [
{
"name": "CPU exhaustion via a truncated gRPC message on streaming RPCs in elixir-grpc grpc_server",
"tags": [
"vendor-advisory",
"related"
],
"url": "https://github.com/elixir-grpc/grpc/security/advisories/GHSA-c2h9-f9cc-27xh"
},
{
"name": "EEF CNA record for CVE-2026-121809",
"tags": [
"related"
],
"url": "https://cna.erlef.org/cves/CVE-2026-121809.html"
},
{
"name": "OSV record EEF-CVE-2026-121809",
"tags": [
"related"
],
"url": "https://osv.dev/vulnerability/EEF-CVE-2026-121809"
},
{
"name": "Introducing commit 0332faa in elixir-grpc/grpc",
"tags": [
"related"
],
"url": "https://github.com/elixir-grpc/grpc/commit/0332faae5f5e132314794dbfdae5edecbccea292"
},
{
"name": "Fix commit 38955f1 in elixir-grpc/grpc",
"tags": [
"patch"
],
"url": "https://github.com/elixir-grpc/grpc/commit/38955f1392cbf08eef231a347348445410455c85"
},
{
"name": "Introducing commit 6d8a09a in elixir-grpc/grpc",
"tags": [
"related"
],
"url": "https://github.com/elixir-grpc/grpc/commit/6d8a09ad919e208fb600538ac8b19bd733a04310"
}
],
"source": {
"discovery": "EXTERNAL"
},
"title": "CPU exhaustion via a truncated gRPC message on streaming RPCs in elixir-grpc grpc_server",
"x_proofOfConcept": [
{
"lang": "en",
"supportingMedia": [
{
"base64": false,
"type": "text/html",
"value": "\u003col\u003e\n\u003cli\u003eStart a server that exposes a client-streaming RPC, for example \u003ccode\u003eRouteGuide.RecordRoute\u003c/code\u003e.\u003c/li\u003e\n\u003cli\u003eOpen an HTTP/2 connection and send \u003ccode\u003eHEADERS\u003c/code\u003e for that method with \u003ccode\u003econtent-type: application/grpc\u003c/code\u003e and no \u003ccode\u003egrpc-timeout\u003c/code\u003e.\u003c/li\u003e\n\u003cli\u003eSend one \u003ccode\u003eDATA\u003c/code\u003e frame with \u003ccode\u003eEND_STREAM\u003c/code\u003e that holds only the 5-byte prefix \u003ccode\u003e00 00 00 00 64\u003c/code\u003e, which declares a 100-byte message.\u003c/li\u003e\n\u003cli\u003eKeep the connection open and observe that no response arrives and one BEAM scheduler stays at 100% CPU until the connection closes.\u003c/li\u003e\n\u003c/ol\u003e"
},
{
"base64": false,
"type": "text/markdown",
"value": "1. Start a server that exposes a client-streaming RPC, for example `RouteGuide.RecordRoute`.\n2. Open an HTTP/2 connection and send `HEADERS` for that method with `content-type: application/grpc` and no `grpc-timeout`.\n3. Send one `DATA` frame with `END_STREAM` that holds only the 5-byte prefix `00 00 00 00 64`, which declares a 100-byte message.\n4. Keep the connection open and observe that no response arrives and one BEAM scheduler stays at 100% CPU until the connection closes."
}
],
"value": "* Start a server that exposes a client-streaming RPC, for example RouteGuide.RecordRoute.\n* Open an HTTP/2 connection and send HEADERS for that method with content-type: application/grpc and no grpc-timeout.\n* Send one DATA frame with END_STREAM that holds only the 5-byte prefix 00 00 00 00 64, which declares a 100-byte message.\n* Keep the connection open and observe that no response arrives and one BEAM scheduler stays at 100% CPU until the connection closes."
}
],
"x_technicalAnalysis": [
{
"lang": "en",
"supportingMedia": [
{
"base64": false,
"type": "text/html",
"value": "\u003cp\u003e\u003cstrong\u003e1. A partial message\u003c/strong\u003e\u003c/p\u003e\n\u003cp\u003eA client sends a 5-byte gRPC length prefix that declares more bytes than follow it, and ends the request body with \u003ccode\u003eEND_STREAM\u003c/code\u003e.\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003e2. The reader asks for more\u003c/strong\u003e\u003c/p\u003e\n\u003cp\u003e\u003ccode\u003eread_stream/1\u003c/code\u003e calls \u003ccode\u003eGRPC.Message.get_message/1\u003c/code\u003e, which returns \u003ccode\u003efalse\u003c/code\u003e because the declared message is incomplete. \u003ccode\u003eread_stream/1\u003c/code\u003e then sets \u003ccode\u003eneed_more\u003c/code\u003e and reads again through \u003ccode\u003eHandler.read_body/1\u003c/code\u003e.\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003e3. Every read is empty\u003c/strong\u003e\u003c/p\u003e\n\u003cp\u003eOnce the body has been read, the handler\u0027s \u003ccode\u003easync_read_body/2\u003c/code\u003e (and \u003ccode\u003ecowboy_req:read_body/2\u003c/code\u003e in older releases) returns an empty chunk at once. The buffer stays the same, the exit clause for an empty finished buffer never matches, and the loop repeats.\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003e4. No timeout ends it\u003c/strong\u003e\u003c/p\u003e\n\u003cp\u003eEach iteration is a message round trip between two local processes, not a socket wait, so no I/O timeout applies. Without a \u003ccode\u003egrpc-timeout\u003c/code\u003e header there is no deadline, and the adapter\u0027s idle timeout defaults to \u003ccode\u003e:infinity\u003c/code\u003e. One scheduler stays at full load until the client closes the connection.\u003c/p\u003e"
},
{
"base64": false,
"type": "text/markdown",
"value": "**1. A partial message**\n\nA client sends a 5-byte gRPC length prefix that declares more bytes than follow it, and ends the request body with `END_STREAM`.\n\n**2. The reader asks for more**\n\n`read_stream/1` calls `GRPC.Message.get_message/1`, which returns `false` because the declared message is incomplete. `read_stream/1` then sets `need_more` and reads again through `Handler.read_body/1`.\n\n**3. Every read is empty**\n\nOnce the body has been read, the handler\u0027s `async_read_body/2` (and `cowboy_req:read_body/2` in older releases) returns an empty chunk at once. The buffer stays the same, the exit clause for an empty finished buffer never matches, and the loop repeats.\n\n**4. No timeout ends it**\n\nEach iteration is a message round trip between two local processes, not a socket wait, so no I/O timeout applies. Without a `grpc-timeout` header there is no deadline, and the adapter\u0027s idle timeout defaults to `:infinity`. One scheduler stays at full load until the client closes the connection."
}
],
"value": "1. A partial message\n\nA client sends a 5-byte gRPC length prefix that declares more bytes than follow it, and ends the request body with END_STREAM.\n\n2. The reader asks for more\n\nread_stream/1 calls GRPC.Message.get_message/1, which returns false because the declared message is incomplete. read_stream/1 then sets need_more and reads again through Handler.read_body/1.\n\n3. Every read is empty\n\nOnce the body has been read, the handler\u0027s async_read_body/2 (and cowboy_req:read_body/2 in older releases) returns an empty chunk at once. The buffer stays the same, the exit clause for an empty finished buffer never matches, and the loop repeats.\n\n4. No timeout ends it\n\nEach iteration is a message round trip between two local processes, not a socket wait, so no I/O timeout applies. Without a grpc-timeout header there is no deadline, and the adapter\u0027s idle timeout defaults to :infinity. One scheduler stays at full load until the client closes the connection."
}
]
}
},
"cveMetadata": {
"assignerOrgId": "6b3ad84c-e1a6-4bf7-a703-f496b71e49db",
"assignerShortName": "EEF",
"cveId": "CVE-2026-121809",
"datePublished": "2026-11-13T16:18:37.954Z",
"dateReserved": "2026-11-13T11:19:13.834Z",
"dateUpdated": "2026-11-13T19:32:40.114Z",
"state": "PUBLISHED"
},
"dataType": "CVE_RECORD",
"dataVersion": "5.2"
}
CVE-2026-124278 (GCVE-0-2026-124278)
Vulnerability from cvelistv5 – Published: 2026-11-13 15:58 – Updated: 2026-11-13 15:58
VLAI
EPSS
VEX
Title
Path bindings ignored for gRPC Content-Types on transcoded routes in elixir-grpc grpc_server
Summary
Authorization Bypass Through User-Controlled Key vulnerability in elixir-grpc grpc_server and grpc allows an attacker to make a handler act on a resource other than the one named in the URL of an HTTP-transcoded route, via a request with a gRPC Content-Type.
Only the transcoding clause of GRPC.Server.do_handle_request/4 (grpc_server/lib/grpc/server.ex, and lib/grpc/server.ex before the package split) applies the route's path bindings to the request message. The Cowboy handler picks that clause from the request Content-Type, not from the route. A request to a transcoded URL with application/grpc or application/grpc-web is decoded from the body alone, so every path-bound field takes its value from the body. A gateway, proxy rule or WAF that authorizes on the URL path sees one resource while the handler receives another.
This issue affects grpc_server: from 1.0.0 before 1.1.0; grpc: from 0.8.0 before 1.0.0.
Severity
Assigner
References
6 references
| URL | Tags |
|---|---|
| https://github.com/elixir-grpc/grpc/security/advi… | vendor-advisoryrelated |
| https://cna.erlef.org/cves/CVE-2026-124278.html | related |
| https://osv.dev/vulnerability/EEF-CVE-2026-124278 | related |
| https://github.com/elixir-grpc/grpc/commit/0332fa… | related |
| https://github.com/elixir-grpc/grpc/commit/953ab9… | patch |
| https://github.com/elixir-grpc/grpc/commit/8aaf3d… | related |
Impacted products
4 products
| Vendor | Product | Version | |
|---|---|---|---|
| elixir-grpc | grpc_server |
Affected:
1.0.0 , < 1.1.0
(semver)
cpe:2.3:a:elixir-grpc:grpc_server:*:*:*:*:*:*:*:* |
|
| elixir-grpc | grpc_server |
Affected:
0332faae5f5e132314794dbfdae5edecbccea292 , < 953ab980d8a127e895d3ab9505fc4b9337bb32f7
(git)
cpe:2.3:a:elixir-grpc:grpc_server:*:*:*:*:*:*:*:* |
|
| elixir-grpc | grpc |
Affected:
0.8.0 , < 1.0.0
(semver)
cpe:2.3:a:elixir-grpc:grpc:*:*:*:*:*:*:*:* |
|
| elixir-grpc | grpc |
Affected:
8aaf3d3a8c4c7b08ac65e9c6f254e0d24da1d048 , < 0332faae5f5e132314794dbfdae5edecbccea292
(git)
cpe:2.3:a:elixir-grpc:grpc:*:*:*:*:*:*:*:* |
{
"containers": {
"cna": {
"affected": [
{
"collectionURL": "https://repo.hex.pm",
"cpes": [
"cpe:2.3:a:elixir-grpc:grpc_server:*:*:*:*:*:*:*:*"
],
"defaultStatus": "unaffected",
"modules": [
"\u0027Elixir.GRPC.Server\u0027",
"\u0027Elixir.GRPC.Server.Adapters.Cowboy.Handler\u0027"
],
"packageName": "grpc_server",
"packageURL": "pkg:hex/grpc_server",
"product": "grpc_server",
"programFiles": [
"lib/grpc/server.ex",
"lib/grpc/server/adapters/cowboy/handler.ex"
],
"programRoutines": [
{
"name": "\u0027Elixir.GRPC.Server\u0027:do_handle_request/4"
},
{
"name": "\u0027Elixir.GRPC.Server.Adapters.Cowboy.Handler\u0027:init/2"
}
],
"repo": "https://github.com/elixir-grpc/grpc",
"vendor": "elixir-grpc",
"versions": [
{
"lessThan": "1.1.0",
"status": "affected",
"version": "1.0.0",
"versionType": "semver"
}
]
},
{
"collectionURL": "https://github.com",
"cpes": [
"cpe:2.3:a:elixir-grpc:grpc_server:*:*:*:*:*:*:*:*"
],
"defaultStatus": "unaffected",
"modules": [
"\u0027Elixir.GRPC.Server\u0027",
"\u0027Elixir.GRPC.Server.Adapters.Cowboy.Handler\u0027"
],
"packageName": "elixir-grpc/grpc",
"packageURL": "pkg:github/elixir-grpc/grpc",
"product": "grpc_server",
"programFiles": [
"grpc_server/lib/grpc/server.ex",
"grpc_server/lib/grpc/server/adapters/cowboy/handler.ex"
],
"programRoutines": [
{
"name": "\u0027Elixir.GRPC.Server\u0027:do_handle_request/4"
},
{
"name": "\u0027Elixir.GRPC.Server.Adapters.Cowboy.Handler\u0027:init/2"
}
],
"repo": "https://github.com/elixir-grpc/grpc",
"vendor": "elixir-grpc",
"versions": [
{
"lessThan": "953ab980d8a127e895d3ab9505fc4b9337bb32f7",
"status": "affected",
"version": "0332faae5f5e132314794dbfdae5edecbccea292",
"versionType": "git"
}
]
},
{
"collectionURL": "https://repo.hex.pm",
"cpes": [
"cpe:2.3:a:elixir-grpc:grpc:*:*:*:*:*:*:*:*"
],
"defaultStatus": "unaffected",
"modules": [
"\u0027Elixir.GRPC.Server\u0027",
"\u0027Elixir.GRPC.Server.Adapters.Cowboy.Handler\u0027"
],
"packageName": "grpc",
"packageURL": "pkg:hex/grpc",
"product": "grpc",
"programFiles": [
"lib/grpc/server.ex",
"lib/grpc/server/adapters/cowboy/handler.ex"
],
"programRoutines": [
{
"name": "\u0027Elixir.GRPC.Server\u0027:do_handle_request/4"
},
{
"name": "\u0027Elixir.GRPC.Server.Adapters.Cowboy.Handler\u0027:init/2"
}
],
"repo": "https://github.com/elixir-grpc/grpc",
"vendor": "elixir-grpc",
"versions": [
{
"lessThan": "1.0.0",
"status": "affected",
"version": "0.8.0",
"versionType": "semver"
}
]
},
{
"collectionURL": "https://github.com",
"cpes": [
"cpe:2.3:a:elixir-grpc:grpc:*:*:*:*:*:*:*:*"
],
"defaultStatus": "unaffected",
"modules": [
"\u0027Elixir.GRPC.Server\u0027",
"\u0027Elixir.GRPC.Server.Adapters.Cowboy.Handler\u0027"
],
"packageName": "elixir-grpc/grpc",
"packageURL": "pkg:github/elixir-grpc/grpc",
"product": "grpc",
"programFiles": [
"lib/grpc/server.ex",
"lib/grpc/server/adapters/cowboy/handler.ex"
],
"programRoutines": [
{
"name": "\u0027Elixir.GRPC.Server\u0027:do_handle_request/4"
},
{
"name": "\u0027Elixir.GRPC.Server.Adapters.Cowboy.Handler\u0027:init/2"
}
],
"repo": "https://github.com/elixir-grpc/grpc",
"vendor": "elixir-grpc",
"versions": [
{
"lessThan": "0332faae5f5e132314794dbfdae5edecbccea292",
"status": "affected",
"version": "8aaf3d3a8c4c7b08ac65e9c6f254e0d24da1d048",
"versionType": "git"
}
]
}
],
"configurations": [
{
"lang": "en",
"supportingMedia": [
{
"base64": false,
"type": "text/html",
"value": "\u003cp\u003eHTTP-to-gRPC transcoding must be enabled with \u003ccode\u003ehttp_transcode: true\u003c/code\u003e. Access to a resource must be decided from the URL path rather than from the decoded request, for example by an API gateway, proxy rule or WAF in front of the server.\u003c/p\u003e"
},
{
"base64": false,
"type": "text/markdown",
"value": "HTTP-to-gRPC transcoding must be enabled with `http_transcode: true`. Access to a resource must be decided from the URL path rather than from the decoded request, for example by an API gateway, proxy rule or WAF in front of the server."
}
],
"value": "HTTP-to-gRPC transcoding must be enabled with http_transcode: true. Access to a resource must be decided from the URL path rather than from the decoded request, for example by an API gateway, proxy rule or WAF in front of the server."
}
],
"cpeApplicability": [
{
"nodes": [
{
"cpeMatch": [
{
"criteria": "cpe:2.3:a:elixir-grpc:grpc_server:*:*:*:*:*:*:*:*",
"versionEndExcluding": "1.1.0",
"versionStartIncluding": "1.0.0",
"vulnerable": true
},
{
"criteria": "cpe:2.3:a:elixir-grpc:grpc:*:*:*:*:*:*:*:*",
"versionEndExcluding": "1.0.0",
"versionStartIncluding": "0.8.0",
"vulnerable": true
}
],
"negate": false,
"operator": "OR"
}
],
"operator": "AND"
}
],
"credits": [
{
"lang": "en",
"type": "reporter",
"value": "Eurico Nicacio"
},
{
"lang": "en",
"type": "finder",
"value": "Eurico Nicacio"
},
{
"lang": "en",
"type": "remediation developer",
"value": "Paulo Valente"
},
{
"lang": "en",
"type": "coordinator",
"value": "Peter Ullrich"
},
{
"lang": "en",
"type": "coordinator",
"value": "Jonatan M\u00e4nnchen / EEF"
}
],
"dateAssigned": "2026-11-13T11:18:41.000Z",
"descriptions": [
{
"lang": "en",
"supportingMedia": [
{
"base64": false,
"type": "text/html",
"value": "\u003cp\u003eAuthorization Bypass Through User-Controlled Key vulnerability in elixir-grpc grpc_server and grpc allows an attacker to make a handler act on a resource other than the one named in the URL of an HTTP-transcoded route, via a request with a gRPC Content-Type.\u003c/p\u003e\n\u003cp\u003eOnly the transcoding clause of \u003ccode\u003eGRPC.Server.do_handle_request/4\u003c/code\u003e (\u003ccode\u003egrpc_server/lib/grpc/server.ex\u003c/code\u003e, and \u003ccode\u003elib/grpc/server.ex\u003c/code\u003e before the package split) applies the route\u0027s path bindings to the request message. The Cowboy handler picks that clause from the request \u003ccode\u003eContent-Type\u003c/code\u003e, not from the route. A request to a transcoded URL with \u003ccode\u003eapplication/grpc\u003c/code\u003e or \u003ccode\u003eapplication/grpc-web\u003c/code\u003e is decoded from the body alone, so every path-bound field takes its value from the body. A gateway, proxy rule or WAF that authorizes on the URL path sees one resource while the handler receives another.\u003c/p\u003e\n\u003cp\u003eThis issue affects grpc_server: from 1.0.0 before 1.1.0; grpc: from 0.8.0 before 1.0.0.\u003c/p\u003e"
},
{
"base64": false,
"type": "text/markdown",
"value": "Authorization Bypass Through User-Controlled Key vulnerability in elixir-grpc grpc_server and grpc allows an attacker to make a handler act on a resource other than the one named in the URL of an HTTP-transcoded route, via a request with a gRPC Content-Type.\n\nOnly the transcoding clause of `GRPC.Server.do_handle_request/4` (`grpc_server/lib/grpc/server.ex`, and `lib/grpc/server.ex` before the package split) applies the route\u0027s path bindings to the request message. The Cowboy handler picks that clause from the request `Content-Type`, not from the route. A request to a transcoded URL with `application/grpc` or `application/grpc-web` is decoded from the body alone, so every path-bound field takes its value from the body. A gateway, proxy rule or WAF that authorizes on the URL path sees one resource while the handler receives another.\n\nThis issue affects grpc_server: from 1.0.0 before 1.1.0; grpc: from 0.8.0 before 1.0.0."
}
],
"value": "Authorization Bypass Through User-Controlled Key vulnerability in elixir-grpc grpc_server and grpc allows an attacker to make a handler act on a resource other than the one named in the URL of an HTTP-transcoded route, via a request with a gRPC Content-Type.\n\nOnly the transcoding clause of GRPC.Server.do_handle_request/4 (grpc_server/lib/grpc/server.ex, and lib/grpc/server.ex before the package split) applies the route\u0027s path bindings to the request message. The Cowboy handler picks that clause from the request Content-Type, not from the route. A request to a transcoded URL with application/grpc or application/grpc-web is decoded from the body alone, so every path-bound field takes its value from the body. A gateway, proxy rule or WAF that authorizes on the URL path sees one resource while the handler receives another.\n\nThis issue affects grpc_server: from 1.0.0 before 1.1.0; grpc: from 0.8.0 before 1.0.0."
}
],
"impacts": [
{
"capecId": "CAPEC-460",
"descriptions": [
{
"lang": "en",
"supportingMedia": [
{
"base64": false,
"type": "text/html",
"value": "\u003cp\u003eAn attacker whose requests are authorized by URL path can make the handler read or modify a resource the path does not name, such as another tenant\u0027s data.\u003c/p\u003e"
},
{
"base64": false,
"type": "text/markdown",
"value": "An attacker whose requests are authorized by URL path can make the handler read or modify a resource the path does not name, such as another tenant\u0027s data."
}
],
"value": "An attacker whose requests are authorized by URL path can make the handler read or modify a resource the path does not name, such as another tenant\u0027s data."
}
]
}
],
"metrics": [
{
"cvssV4_0": {
"Automatable": "NOT_DEFINED",
"Recovery": "NOT_DEFINED",
"Safety": "NOT_DEFINED",
"attackComplexity": "LOW",
"attackRequirements": "PRESENT",
"attackVector": "NETWORK",
"baseScore": 7.6,
"baseSeverity": "HIGH",
"privilegesRequired": "LOW",
"providerUrgency": "NOT_DEFINED",
"subAvailabilityImpact": "NONE",
"subConfidentialityImpact": "NONE",
"subIntegrityImpact": "NONE",
"userInteraction": "NONE",
"valueDensity": "NOT_DEFINED",
"vectorString": "CVSS:4.0/AV:N/AC:L/AT:P/PR:L/UI:N/VC:H/VI:H/VA:N/SC:N/SI:N/SA:N",
"version": "4.0",
"vulnAvailabilityImpact": "NONE",
"vulnConfidentialityImpact": "HIGH",
"vulnIntegrityImpact": "HIGH",
"vulnerabilityResponseEffort": "NOT_DEFINED"
},
"format": "CVSS",
"scenarios": [
{
"lang": "en",
"value": "GENERAL"
}
]
}
],
"problemTypes": [
{
"descriptions": [
{
"cweId": "CWE-436",
"description": "CWE-436 Interpretation Conflict",
"lang": "en",
"type": "CWE"
}
]
},
{
"descriptions": [
{
"cweId": "CWE-639",
"description": "CWE-639 Authorization Bypass Through User-Controlled Key",
"lang": "en",
"type": "CWE"
}
]
}
],
"providerMetadata": {
"dateUpdated": "2026-11-13T15:58:08.134Z",
"orgId": "6b3ad84c-e1a6-4bf7-a703-f496b71e49db",
"shortName": "EEF"
},
"references": [
{
"name": "Path bindings ignored for gRPC Content-Types on transcoded routes in elixir-grpc grpc_server",
"tags": [
"vendor-advisory",
"related"
],
"url": "https://github.com/elixir-grpc/grpc/security/advisories/GHSA-xc5v-89vq-hv5m"
},
{
"name": "EEF CNA record for CVE-2026-124278",
"tags": [
"related"
],
"url": "https://cna.erlef.org/cves/CVE-2026-124278.html"
},
{
"name": "OSV record EEF-CVE-2026-124278",
"tags": [
"related"
],
"url": "https://osv.dev/vulnerability/EEF-CVE-2026-124278"
},
{
"name": "Introducing commit 0332faa in elixir-grpc/grpc",
"tags": [
"related"
],
"url": "https://github.com/elixir-grpc/grpc/commit/0332faae5f5e132314794dbfdae5edecbccea292"
},
{
"name": "Fix commit 953ab98 in elixir-grpc/grpc",
"tags": [
"patch"
],
"url": "https://github.com/elixir-grpc/grpc/commit/953ab980d8a127e895d3ab9505fc4b9337bb32f7"
},
{
"name": "Introducing commit 8aaf3d3 in elixir-grpc/grpc",
"tags": [
"related"
],
"url": "https://github.com/elixir-grpc/grpc/commit/8aaf3d3a8c4c7b08ac65e9c6f254e0d24da1d048"
}
],
"source": {
"discovery": "EXTERNAL"
},
"title": "Path bindings ignored for gRPC Content-Types on transcoded routes in elixir-grpc grpc_server",
"x_proofOfConcept": [
{
"lang": "en",
"supportingMedia": [
{
"base64": false,
"type": "text/html",
"value": "\u003col\u003e\n\u003cli\u003eStart a server with \u003ccode\u003ehttp_transcode: true\u003c/code\u003e and a transcoded route that binds a field from the path, for example \u003ccode\u003eGET /v1/messages/{name}\u003c/code\u003e.\u003c/li\u003e\n\u003cli\u003eBuild a gRPC frame whose message sets \u003ccode\u003ename\u003c/code\u003e to \u003ccode\u003evictim\u003c/code\u003e.\u003c/li\u003e\n\u003cli\u003eSend it over HTTP/1.1 as \u003ccode\u003eGET /v1/messages/alice\u003c/code\u003e with \u003ccode\u003eContent-Type: application/grpc\u003c/code\u003e.\u003c/li\u003e\n\u003cli\u003eObserve that the handler receives \u003ccode\u003ename\u003c/code\u003e set to \u003ccode\u003evictim\u003c/code\u003e, not \u003ccode\u003ealice\u003c/code\u003e.\u003c/li\u003e\n\u003c/ol\u003e"
},
{
"base64": false,
"type": "text/markdown",
"value": "1. Start a server with `http_transcode: true` and a transcoded route that binds a field from the path, for example `GET /v1/messages/{name}`.\n2. Build a gRPC frame whose message sets `name` to `victim`.\n3. Send it over HTTP/1.1 as `GET /v1/messages/alice` with `Content-Type: application/grpc`.\n4. Observe that the handler receives `name` set to `victim`, not `alice`."
}
],
"value": "* Start a server with http_transcode: true and a transcoded route that binds a field from the path, for example GET /v1/messages/{name}.\n* Build a gRPC frame whose message sets name to victim.\n* Send it over HTTP/1.1 as GET /v1/messages/alice with Content-Type: application/grpc.\n* Observe that the handler receives name set to victim, not alice."
}
],
"x_technicalAnalysis": [
{
"lang": "en",
"supportingMedia": [
{
"base64": false,
"type": "text/html",
"value": "\u003cp\u003e\u003cstrong\u003e1. The header selects the clause\u003c/strong\u003e\u003c/p\u003e\n\u003cp\u003eThe Cowboy handler derives the access mode from the request \u003ccode\u003eContent-Type\u003c/code\u003e. \u003ccode\u003eapplication/json\u003c/code\u003e selects \u003ccode\u003e:http_transcoding\u003c/code\u003e, \u003ccode\u003eapplication/grpc\u003c/code\u003e selects \u003ccode\u003e:grpc\u003c/code\u003e and \u003ccode\u003eapplication/grpc-web+proto\u003c/code\u003e selects \u003ccode\u003e:grpcweb\u003c/code\u003e. The router still matches the transcoded URL template and extracts its path bindings.\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003e2. Bindings are applied in one clause only\u003c/strong\u003e\u003c/p\u003e\n\u003cp\u003e\u003ccode\u003eGRPC.Server.do_handle_request/4\u003c/code\u003e reads the bindings with \u003ccode\u003eadapter.get_bindings/1\u003c/code\u003e and merges them through \u003ccode\u003eGRPC.Server.Transcode.map_request/5\u003c/code\u003e only in the \u003ccode\u003e:http_transcoding\u003c/code\u003e clause. The unary and request-streaming clauses decode the body alone with the selected codec. \u003ccode\u003eGRPC.Codec.Proto\u003c/code\u003e is in the default codecs of every server, so \u003ccode\u003eapplication/grpc\u003c/code\u003e is always accepted.\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003e3. The HTTP/1.1 guard never matches\u003c/strong\u003e\u003c/p\u003e\n\u003cp\u003e\u003ccode\u003eresolve_access_mode/3\u003c/code\u003e has a clause that forces transcoding for HTTP/1.1 requests. It matches the version as the binary \u003ccode\u003e\"HTTP/1.1\"\u003c/code\u003e, while Cowboy stores the version as an atom, so the clause never applies and the request works over plain HTTP/1.1.\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003e4. Result\u003c/strong\u003e\u003c/p\u003e\n\u003cp\u003eA request to \u003ccode\u003e/v1/messages/alice\u003c/code\u003e with \u003ccode\u003eContent-Type: application/grpc\u003c/code\u003e and a message that sets \u003ccode\u003ename\u003c/code\u003e to \u003ccode\u003evictim\u003c/code\u003e reaches the handler with \u003ccode\u003ename\u003c/code\u003e set to \u003ccode\u003evictim\u003c/code\u003e. With an empty message the field is empty: the path binding is never consulted.\u003c/p\u003e"
},
{
"base64": false,
"type": "text/markdown",
"value": "**1. The header selects the clause**\n\nThe Cowboy handler derives the access mode from the request `Content-Type`. `application/json` selects `:http_transcoding`, `application/grpc` selects `:grpc` and `application/grpc-web+proto` selects `:grpcweb`. The router still matches the transcoded URL template and extracts its path bindings.\n\n**2. Bindings are applied in one clause only**\n\n`GRPC.Server.do_handle_request/4` reads the bindings with `adapter.get_bindings/1` and merges them through `GRPC.Server.Transcode.map_request/5` only in the `:http_transcoding` clause. The unary and request-streaming clauses decode the body alone with the selected codec. `GRPC.Codec.Proto` is in the default codecs of every server, so `application/grpc` is always accepted.\n\n**3. The HTTP/1.1 guard never matches**\n\n`resolve_access_mode/3` has a clause that forces transcoding for HTTP/1.1 requests. It matches the version as the binary `\"HTTP/1.1\"`, while Cowboy stores the version as an atom, so the clause never applies and the request works over plain HTTP/1.1.\n\n**4. Result**\n\nA request to `/v1/messages/alice` with `Content-Type: application/grpc` and a message that sets `name` to `victim` reaches the handler with `name` set to `victim`. With an empty message the field is empty: the path binding is never consulted."
}
],
"value": "1. The header selects the clause\n\nThe Cowboy handler derives the access mode from the request Content-Type. application/json selects :http_transcoding, application/grpc selects :grpc and application/grpc-web+proto selects :grpcweb. The router still matches the transcoded URL template and extracts its path bindings.\n\n2. Bindings are applied in one clause only\n\nGRPC.Server.do_handle_request/4 reads the bindings with adapter.get_bindings/1 and merges them through GRPC.Server.Transcode.map_request/5 only in the :http_transcoding clause. The unary and request-streaming clauses decode the body alone with the selected codec. GRPC.Codec.Proto is in the default codecs of every server, so application/grpc is always accepted.\n\n3. The HTTP/1.1 guard never matches\n\nresolve_access_mode/3 has a clause that forces transcoding for HTTP/1.1 requests. It matches the version as the binary \"HTTP/1.1\", while Cowboy stores the version as an atom, so the clause never applies and the request works over plain HTTP/1.1.\n\n4. Result\n\nA request to /v1/messages/alice with Content-Type: application/grpc and a message that sets name to victim reaches the handler with name set to victim. With an empty message the field is empty: the path binding is never consulted."
}
]
}
},
"cveMetadata": {
"assignerOrgId": "6b3ad84c-e1a6-4bf7-a703-f496b71e49db",
"assignerShortName": "EEF",
"cveId": "CVE-2026-124278",
"datePublished": "2026-11-13T15:58:08.134Z",
"dateReserved": "2026-11-09T23:03:52.038Z",
"dateUpdated": "2026-11-13T15:58:08.134Z",
"state": "PUBLISHED"
},
"dataType": "CVE_RECORD",
"dataVersion": "5.2"
}
CVE-2026-121808 (GCVE-0-2026-121808)
Vulnerability from cvelistv5 – Published: 2026-11-13 15:48 – Updated: 2026-11-13 15:48
VLAI
EPSS
VEX
Title
Memory exhaustion via compressed Erlang terms in the elixir-grpc Erlpack codec
Summary
Improper Handling of Highly Compressed Data (Data Amplification) vulnerability in elixir-grpc grpc_core and grpc allows unauthenticated attackers to exhaust server memory via a compressed Erlang external term in a gRPC message body.
GRPC.Codec.Erlpack.decode/2 (grpc_core/lib/grpc/codec/erlpack.ex, and lib/grpc/codec/erlpack.ex before the package split) passes the message body to :erlang.binary_to_term/2. That function inflates a compressed term in full before it returns. The codec sets no limit on the decoded size, and its type checks run only on the decoded term. The server's max_body_size limit counts the compressed bytes of the request. A request with Content-Type: application/grpc+erlpack can therefore make the server allocate a term much larger than the request body.
This issue affects grpc: from 0.4.0 before 1.0.0; grpc_core: from 1.0.0 before 1.1.0.
Severity
CWE
- CWE-409 - Improper Handling of Highly Compressed Data (Data Amplification)
Assigner
References
6 references
Impacted products
4 products
| Vendor | Product | Version | |
|---|---|---|---|
| elixir-grpc | grpc |
Affected:
0.4.0 , < 1.0.0
(semver)
cpe:2.3:a:elixir-grpc:grpc:*:*:*:*:*:*:*:* |
|
| elixir-grpc | grpc |
Affected:
25bcc569fe2cc4478531a6c546c923205fc751c9 , < 0332faae5f5e132314794dbfdae5edecbccea292
(git)
cpe:2.3:a:elixir-grpc:grpc:*:*:*:*:*:*:*:* |
|
| elixir-grpc | grpc_core |
Affected:
1.0.0 , < 1.1.0
(semver)
cpe:2.3:a:elixir-grpc:grpc_core:*:*:*:*:*:*:*:* |
|
| elixir-grpc | grpc_core |
Affected:
0332faae5f5e132314794dbfdae5edecbccea292 , < d6bc56ac75dfb6c02db2099909210a37c9be8db6
(git)
cpe:2.3:a:elixir-grpc:grpc_core:*:*:*:*:*:*:*:* |
{
"containers": {
"cna": {
"affected": [
{
"collectionURL": "https://repo.hex.pm",
"cpes": [
"cpe:2.3:a:elixir-grpc:grpc:*:*:*:*:*:*:*:*"
],
"defaultStatus": "unaffected",
"modules": [
"\u0027Elixir.GRPC.Codec.Erlpack\u0027"
],
"packageName": "grpc",
"packageURL": "pkg:hex/grpc",
"product": "grpc",
"programFiles": [
"lib/grpc/codec/erlpack.ex"
],
"programRoutines": [
{
"name": "\u0027Elixir.GRPC.Codec.Erlpack\u0027:decode/2"
}
],
"repo": "https://github.com/elixir-grpc/grpc",
"vendor": "elixir-grpc",
"versions": [
{
"lessThan": "1.0.0",
"status": "affected",
"version": "0.4.0",
"versionType": "semver"
}
]
},
{
"collectionURL": "https://github.com",
"cpes": [
"cpe:2.3:a:elixir-grpc:grpc:*:*:*:*:*:*:*:*"
],
"defaultStatus": "unaffected",
"modules": [
"\u0027Elixir.GRPC.Codec.Erlpack\u0027"
],
"packageName": "elixir-grpc/grpc",
"packageURL": "pkg:github/elixir-grpc/grpc",
"product": "grpc",
"programFiles": [
"lib/grpc/codec/erlpack.ex"
],
"programRoutines": [
{
"name": "\u0027Elixir.GRPC.Codec.Erlpack\u0027:decode/2"
}
],
"repo": "https://github.com/elixir-grpc/grpc",
"vendor": "elixir-grpc",
"versions": [
{
"lessThan": "0332faae5f5e132314794dbfdae5edecbccea292",
"status": "affected",
"version": "25bcc569fe2cc4478531a6c546c923205fc751c9",
"versionType": "git"
}
]
},
{
"collectionURL": "https://repo.hex.pm",
"cpes": [
"cpe:2.3:a:elixir-grpc:grpc_core:*:*:*:*:*:*:*:*"
],
"defaultStatus": "unaffected",
"modules": [
"\u0027Elixir.GRPC.Codec.Erlpack\u0027"
],
"packageName": "grpc_core",
"packageURL": "pkg:hex/grpc_core",
"product": "grpc_core",
"programFiles": [
"lib/grpc/codec/erlpack.ex"
],
"programRoutines": [
{
"name": "\u0027Elixir.GRPC.Codec.Erlpack\u0027:decode/2"
}
],
"repo": "https://github.com/elixir-grpc/grpc",
"vendor": "elixir-grpc",
"versions": [
{
"lessThan": "1.1.0",
"status": "affected",
"version": "1.0.0",
"versionType": "semver"
}
]
},
{
"collectionURL": "https://github.com",
"cpes": [
"cpe:2.3:a:elixir-grpc:grpc_core:*:*:*:*:*:*:*:*"
],
"defaultStatus": "unaffected",
"modules": [
"\u0027Elixir.GRPC.Codec.Erlpack\u0027"
],
"packageName": "elixir-grpc/grpc",
"packageURL": "pkg:github/elixir-grpc/grpc",
"product": "grpc_core",
"programFiles": [
"grpc_core/lib/grpc/codec/erlpack.ex"
],
"programRoutines": [
{
"name": "\u0027Elixir.GRPC.Codec.Erlpack\u0027:decode/2"
}
],
"repo": "https://github.com/elixir-grpc/grpc",
"vendor": "elixir-grpc",
"versions": [
{
"lessThan": "d6bc56ac75dfb6c02db2099909210a37c9be8db6",
"status": "affected",
"version": "0332faae5f5e132314794dbfdae5edecbccea292",
"versionType": "git"
}
]
}
],
"configurations": [
{
"lang": "en",
"supportingMedia": [
{
"base64": false,
"type": "text/html",
"value": "\u003cp\u003e\u003ccode\u003eGRPC.Codec.Erlpack\u003c/code\u003e must be explicitly registered as a codec on the gRPC server.\u003c/p\u003e"
},
{
"base64": false,
"type": "text/markdown",
"value": "`GRPC.Codec.Erlpack` must be explicitly registered as a codec on the gRPC server."
}
],
"value": "GRPC.Codec.Erlpack must be explicitly registered as a codec on the gRPC server."
}
],
"cpeApplicability": [
{
"nodes": [
{
"cpeMatch": [
{
"criteria": "cpe:2.3:a:elixir-grpc:grpc:*:*:*:*:*:*:*:*",
"versionEndExcluding": "1.0.0",
"versionStartIncluding": "0.4.0",
"vulnerable": true
},
{
"criteria": "cpe:2.3:a:elixir-grpc:grpc_core:*:*:*:*:*:*:*:*",
"versionEndExcluding": "1.1.0",
"versionStartIncluding": "1.0.0",
"vulnerable": true
}
],
"negate": false,
"operator": "OR"
}
],
"operator": "AND"
}
],
"credits": [
{
"lang": "en",
"type": "reporter",
"value": "Jace"
},
{
"lang": "en",
"type": "finder",
"value": "zx"
},
{
"lang": "en",
"type": "reporter",
"value": "zx"
},
{
"lang": "en",
"type": "finder",
"value": "Jiacheng Zhong"
},
{
"lang": "en",
"type": "finder",
"value": "Zhun Wang"
},
{
"lang": "en",
"type": "finder",
"value": "Junmin Zhu"
},
{
"lang": "en",
"type": "finder",
"value": "Hongwei Li"
},
{
"lang": "en",
"type": "finder",
"value": "Han Dai"
},
{
"lang": "en",
"type": "finder",
"value": "Suyue Guo"
},
{
"lang": "en",
"type": "finder",
"value": "Songyang Peng"
},
{
"lang": "en",
"type": "finder",
"value": "Ziyue Pan"
},
{
"lang": "en",
"type": "reporter",
"value": "Hongwei Li"
},
{
"lang": "en",
"type": "remediation developer",
"value": "Paulo Valente"
},
{
"lang": "en",
"type": "coordinator",
"value": "Jonatan M\u00e4nnchen / EEF"
},
{
"lang": "en",
"type": "coordinator",
"value": "Peter Ullrich"
}
],
"dateAssigned": "2026-11-12T20:11:46.000Z",
"descriptions": [
{
"lang": "en",
"supportingMedia": [
{
"base64": false,
"type": "text/html",
"value": "\u003cp\u003eImproper Handling of Highly Compressed Data (Data Amplification) vulnerability in elixir-grpc grpc_core and grpc allows unauthenticated attackers to exhaust server memory via a compressed Erlang external term in a gRPC message body.\u003c/p\u003e\n\u003cp\u003e\u003ccode\u003eGRPC.Codec.Erlpack.decode/2\u003c/code\u003e (\u003ccode\u003egrpc_core/lib/grpc/codec/erlpack.ex\u003c/code\u003e, and \u003ccode\u003elib/grpc/codec/erlpack.ex\u003c/code\u003e before the package split) passes the message body to \u003ccode\u003e:erlang.binary_to_term/2\u003c/code\u003e. That function inflates a compressed term in full before it returns. The codec sets no limit on the decoded size, and its type checks run only on the decoded term. The server\u0027s \u003ccode\u003emax_body_size\u003c/code\u003e limit counts the compressed bytes of the request. A request with \u003ccode\u003eContent-Type: application/grpc+erlpack\u003c/code\u003e can therefore make the server allocate a term much larger than the request body.\u003c/p\u003e\n\u003cp\u003eThis issue affects grpc: from 0.4.0 before 1.0.0; grpc_core: from 1.0.0 before 1.1.0.\u003c/p\u003e"
},
{
"base64": false,
"type": "text/markdown",
"value": "Improper Handling of Highly Compressed Data (Data Amplification) vulnerability in elixir-grpc grpc_core and grpc allows unauthenticated attackers to exhaust server memory via a compressed Erlang external term in a gRPC message body.\n\n`GRPC.Codec.Erlpack.decode/2` (`grpc_core/lib/grpc/codec/erlpack.ex`, and `lib/grpc/codec/erlpack.ex` before the package split) passes the message body to `:erlang.binary_to_term/2`. That function inflates a compressed term in full before it returns. The codec sets no limit on the decoded size, and its type checks run only on the decoded term. The server\u0027s `max_body_size` limit counts the compressed bytes of the request. A request with `Content-Type: application/grpc+erlpack` can therefore make the server allocate a term much larger than the request body.\n\nThis issue affects grpc: from 0.4.0 before 1.0.0; grpc_core: from 1.0.0 before 1.1.0."
}
],
"value": "Improper Handling of Highly Compressed Data (Data Amplification) vulnerability in elixir-grpc grpc_core and grpc allows unauthenticated attackers to exhaust server memory via a compressed Erlang external term in a gRPC message body.\n\nGRPC.Codec.Erlpack.decode/2 (grpc_core/lib/grpc/codec/erlpack.ex, and lib/grpc/codec/erlpack.ex before the package split) passes the message body to :erlang.binary_to_term/2. That function inflates a compressed term in full before it returns. The codec sets no limit on the decoded size, and its type checks run only on the decoded term. The server\u0027s max_body_size limit counts the compressed bytes of the request. A request with Content-Type: application/grpc+erlpack can therefore make the server allocate a term much larger than the request body.\n\nThis issue affects grpc: from 0.4.0 before 1.0.0; grpc_core: from 1.0.0 before 1.1.0."
}
],
"impacts": [
{
"capecId": "CAPEC-130",
"descriptions": [
{
"lang": "en",
"supportingMedia": [
{
"base64": false,
"type": "text/html",
"value": "\u003cp\u003eAn unauthenticated client can make a server that registers the Erlpack codec allocate memory far beyond the size of the request with one request. Memory exhaustion affects every service on that node.\u003c/p\u003e"
},
{
"base64": false,
"type": "text/markdown",
"value": "An unauthenticated client can make a server that registers the Erlpack codec allocate memory far beyond the size of the request with one request. Memory exhaustion affects every service on that node."
}
],
"value": "An unauthenticated client can make a server that registers the Erlpack codec allocate memory far beyond the size of the request with one request. Memory exhaustion affects every service on that node."
}
]
}
],
"metrics": [
{
"cvssV4_0": {
"Automatable": "NOT_DEFINED",
"Recovery": "NOT_DEFINED",
"Safety": "NOT_DEFINED",
"attackComplexity": "LOW",
"attackRequirements": "PRESENT",
"attackVector": "NETWORK",
"baseScore": 8.2,
"baseSeverity": "HIGH",
"privilegesRequired": "NONE",
"providerUrgency": "NOT_DEFINED",
"subAvailabilityImpact": "NONE",
"subConfidentialityImpact": "NONE",
"subIntegrityImpact": "NONE",
"userInteraction": "NONE",
"valueDensity": "NOT_DEFINED",
"vectorString": "CVSS:4.0/AV:N/AC:L/AT:P/PR:N/UI:N/VC:N/VI:N/VA:H/SC:N/SI:N/SA:N",
"version": "4.0",
"vulnAvailabilityImpact": "HIGH",
"vulnConfidentialityImpact": "NONE",
"vulnIntegrityImpact": "NONE",
"vulnerabilityResponseEffort": "NOT_DEFINED"
},
"format": "CVSS",
"scenarios": [
{
"lang": "en",
"value": "GENERAL"
}
]
}
],
"problemTypes": [
{
"descriptions": [
{
"cweId": "CWE-409",
"description": "CWE-409 Improper Handling of Highly Compressed Data (Data Amplification)",
"lang": "en",
"type": "CWE"
}
]
}
],
"providerMetadata": {
"dateUpdated": "2026-11-13T15:48:49.871Z",
"orgId": "6b3ad84c-e1a6-4bf7-a703-f496b71e49db",
"shortName": "EEF"
},
"references": [
{
"name": "Memory exhaustion via compressed Erlang terms in the elixir-grpc Erlpack codec",
"tags": [
"vendor-advisory",
"related"
],
"url": "https://github.com/elixir-grpc/grpc/security/advisories/GHSA-xxp9-7hp4-rqwp"
},
{
"name": "EEF CNA record for CVE-2026-121808",
"tags": [
"related"
],
"url": "https://cna.erlef.org/cves/CVE-2026-121808.html"
},
{
"name": "OSV record EEF-CVE-2026-121808",
"tags": [
"related"
],
"url": "https://osv.dev/vulnerability/EEF-CVE-2026-121808"
},
{
"name": "Introducing commit 25bcc56 in elixir-grpc/grpc",
"tags": [
"related"
],
"url": "https://github.com/elixir-grpc/grpc/commit/25bcc569fe2cc4478531a6c546c923205fc751c9"
},
{
"name": "Fix commit 0332faa in elixir-grpc/grpc",
"tags": [
"patch"
],
"url": "https://github.com/elixir-grpc/grpc/commit/0332faae5f5e132314794dbfdae5edecbccea292"
},
{
"name": "Fix commit d6bc56a in elixir-grpc/grpc",
"tags": [
"patch"
],
"url": "https://github.com/elixir-grpc/grpc/commit/d6bc56ac75dfb6c02db2099909210a37c9be8db6"
}
],
"source": {
"discovery": "EXTERNAL"
},
"title": "Memory exhaustion via compressed Erlang terms in the elixir-grpc Erlpack codec",
"workarounds": [
{
"lang": "en",
"supportingMedia": [
{
"base64": false,
"type": "text/html",
"value": "\u003cp\u003eRemove \u003ccode\u003eGRPC.Codec.Erlpack\u003c/code\u003e from the server\u0027s \u003ccode\u003ecodecs\u003c/code\u003e list if the codec is not needed.\u003c/p\u003e\n\u003cp\u003eIf the codec is needed, register a wrapper codec in its place. The wrapper implements the \u003ccode\u003eGRPC.Codec\u003c/code\u003e behaviour, returns \u003ccode\u003e\"erlpack\"\u003c/code\u003e from \u003ccode\u003ename/0\u003c/code\u003e, raises in \u003ccode\u003edecode/2\u003c/code\u003e when the body starts with the bytes \u003ccode\u003e131, 80\u003c/code\u003e (the compressed term tag), and delegates all other calls to \u003ccode\u003eGRPC.Codec.Erlpack\u003c/code\u003e.\u003c/p\u003e"
},
{
"base64": false,
"type": "text/markdown",
"value": "Remove `GRPC.Codec.Erlpack` from the server\u0027s `codecs` list if the codec is not needed.\n\nIf the codec is needed, register a wrapper codec in its place. The wrapper implements the `GRPC.Codec` behaviour, returns `\"erlpack\"` from `name/0`, raises in `decode/2` when the body starts with the bytes `131, 80` (the compressed term tag), and delegates all other calls to `GRPC.Codec.Erlpack`."
}
],
"value": "Remove GRPC.Codec.Erlpack from the server\u0027s codecs list if the codec is not needed.\n\nIf the codec is needed, register a wrapper codec in its place. The wrapper implements the GRPC.Codec behaviour, returns \"erlpack\" from name/0, raises in decode/2 when the body starts with the bytes 131, 80 (the compressed term tag), and delegates all other calls to GRPC.Codec.Erlpack."
}
],
"x_proofOfConcept": [
{
"lang": "en",
"supportingMedia": [
{
"base64": false,
"type": "text/html",
"value": "\u003col\u003e\n\u003cli\u003eStart a gRPC server that registers \u003ccode\u003eGRPC.Codec.Erlpack\u003c/code\u003e in its \u003ccode\u003ecodecs\u003c/code\u003e list.\u003c/li\u003e\n\u003cli\u003eBuild the payload with \u003ccode\u003e:erlang.term_to_binary(:binary.copy(\u0026lt;\u0026lt;0\u0026gt;\u0026gt;, 268_435_456), [:compressed])\u003c/code\u003e. The result is 260,932 bytes.\u003c/li\u003e\n\u003cli\u003eSend the payload as one uncompressed gRPC message (compressed flag 0) to any method of the server, with \u003ccode\u003eContent-Type: application/grpc+erlpack\u003c/code\u003e.\u003c/li\u003e\n\u003cli\u003eObserve that the server allocates the 268,435,456-byte binary and passes it to the application callback.\u003c/li\u003e\n\u003c/ol\u003e"
},
{
"base64": false,
"type": "text/markdown",
"value": "1. Start a gRPC server that registers `GRPC.Codec.Erlpack` in its `codecs` list.\n2. Build the payload with `:erlang.term_to_binary(:binary.copy(\u003c\u003c0\u003e\u003e, 268_435_456), [:compressed])`. The result is 260,932 bytes.\n3. Send the payload as one uncompressed gRPC message (compressed flag 0) to any method of the server, with `Content-Type: application/grpc+erlpack`.\n4. Observe that the server allocates the 268,435,456-byte binary and passes it to the application callback."
}
],
"value": "* Start a gRPC server that registers GRPC.Codec.Erlpack in its codecs list.\n* Build the payload with :erlang.term_to_binary(:binary.copy(\u003c\u003c0\u003e\u003e, 268_435_456), [:compressed]). The result is 260,932 bytes.\n* Send the payload as one uncompressed gRPC message (compressed flag 0) to any method of the server, with Content-Type: application/grpc+erlpack.\n* Observe that the server allocates the 268,435,456-byte binary and passes it to the application callback."
}
],
"x_technicalAnalysis": [
{
"lang": "en",
"supportingMedia": [
{
"base64": false,
"type": "text/html",
"value": "\u003cp\u003e\u003cstrong\u003e1. Codec selection\u003c/strong\u003e\u003c/p\u003e\n\u003cp\u003eThe server selects the codec from the request \u003ccode\u003eContent-Type\u003c/code\u003e. \u003ccode\u003eapplication/grpc+erlpack\u003c/code\u003e selects \u003ccode\u003eGRPC.Codec.Erlpack\u003c/code\u003e when the server registers it. No authentication step comes before this.\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003e2. Size limit on compressed bytes only\u003c/strong\u003e\u003c/p\u003e\n\u003cp\u003eThe server limits the request body with \u003ccode\u003emax_body_size\u003c/code\u003e, which counts the bytes received. With the gRPC compressed flag set to 0, the transport decompression limit does not apply, and the message reaches the codec unchanged.\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003e3. Expansion inside \u003ccode\u003ebinary_to_term/2\u003c/code\u003e\u003c/strong\u003e\u003c/p\u003e\n\u003cp\u003e\u003ccode\u003eGRPC.Codec.Erlpack.decode/2\u003c/code\u003e calls \u003ccode\u003e:erlang.binary_to_term(binary, [:safe])\u003c/code\u003e. The Erlang external term format has a compressed form (version byte 131, tag 80, a 32-bit uncompressed size, then a zlib stream). \u003ccode\u003ebinary_to_term/2\u003c/code\u003e inflates it in full, and the \u003ccode\u003e:safe\u003c/code\u003e option does not limit the size. \u003ccode\u003eensure_safe_term!/1\u003c/code\u003e runs after the call returns and accepts a large binary.\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003e4. Result\u003c/strong\u003e\u003c/p\u003e\n\u003cp\u003eIn a test against grpc_server 1.0.6, a 260,932-byte request body decoded to a 268,435,456-byte binary, which the server passed to the application callback.\u003c/p\u003e"
},
{
"base64": false,
"type": "text/markdown",
"value": "**1. Codec selection**\n\nThe server selects the codec from the request `Content-Type`. `application/grpc+erlpack` selects `GRPC.Codec.Erlpack` when the server registers it. No authentication step comes before this.\n\n**2. Size limit on compressed bytes only**\n\nThe server limits the request body with `max_body_size`, which counts the bytes received. With the gRPC compressed flag set to 0, the transport decompression limit does not apply, and the message reaches the codec unchanged.\n\n**3. Expansion inside `binary_to_term/2`**\n\n`GRPC.Codec.Erlpack.decode/2` calls `:erlang.binary_to_term(binary, [:safe])`. The Erlang external term format has a compressed form (version byte 131, tag 80, a 32-bit uncompressed size, then a zlib stream). `binary_to_term/2` inflates it in full, and the `:safe` option does not limit the size. `ensure_safe_term!/1` runs after the call returns and accepts a large binary.\n\n**4. Result**\n\nIn a test against grpc_server 1.0.6, a 260,932-byte request body decoded to a 268,435,456-byte binary, which the server passed to the application callback."
}
],
"value": "1. Codec selection\n\nThe server selects the codec from the request Content-Type. application/grpc+erlpack selects GRPC.Codec.Erlpack when the server registers it. No authentication step comes before this.\n\n2. Size limit on compressed bytes only\n\nThe server limits the request body with max_body_size, which counts the bytes received. With the gRPC compressed flag set to 0, the transport decompression limit does not apply, and the message reaches the codec unchanged.\n\n3. Expansion inside binary_to_term/2\n\nGRPC.Codec.Erlpack.decode/2 calls :erlang.binary_to_term(binary, [:safe]). The Erlang external term format has a compressed form (version byte 131, tag 80, a 32-bit uncompressed size, then a zlib stream). binary_to_term/2 inflates it in full, and the :safe option does not limit the size. ensure_safe_term!/1 runs after the call returns and accepts a large binary.\n\n4. Result\n\nIn a test against grpc_server 1.0.6, a 260,932-byte request body decoded to a 268,435,456-byte binary, which the server passed to the application callback."
}
]
}
},
"cveMetadata": {
"assignerOrgId": "6b3ad84c-e1a6-4bf7-a703-f496b71e49db",
"assignerShortName": "EEF",
"cveId": "CVE-2026-121808",
"datePublished": "2026-11-13T15:48:49.871Z",
"dateReserved": "2026-11-09T22:35:50.462Z",
"dateUpdated": "2026-11-13T15:48:49.871Z",
"state": "PUBLISHED"
},
"dataType": "CVE_RECORD",
"dataVersion": "5.2"
}
CVE-2026-123635 (GCVE-0-2026-123635)
Vulnerability from cvelistv5 – Published: 2026-11-13 15:40 – Updated: 2026-11-13 15:40
VLAI
EPSS
VEX
Title
Refused calls drain the HTTP/2 connection flow-control window in elixir-grpc grpc_server
Summary
Missing Release of Resource after Effective Lifetime vulnerability in elixir-grpc grpc_server allows a client to stall all later calls on a shared HTTP/2 connection via calls that the server refuses while their DATA frames are in flight.
The stream handler grpc_stream_h (grpc_server/src/grpc_stream_h.erl) requests flow from Cowboy only for request data that has arrived. When the server refuses a call early, for example because the body exceeds max_body_size or the method is not implemented, Cowboy drops the DATA frames still in flight for that stream. Their bytes are not returned to the connection flow-control window. After enough refused calls the window is empty. No call on that connection can then send a request body, and each call waits until its deadline. Other connections are not affected.
This issue affects grpc_server: from 1.0.0 before 1.1.0.
Severity
CWE
- CWE-772 - Missing Release of Resource after Effective Lifetime
Assigner
References
5 references
| URL | Tags |
|---|---|
| https://github.com/elixir-grpc/grpc/security/advi… | vendor-advisoryrelated |
| https://cna.erlef.org/cves/CVE-2026-123635.html | related |
| https://osv.dev/vulnerability/EEF-CVE-2026-123635 | related |
| https://github.com/elixir-grpc/grpc/commit/0332fa… | related |
| https://github.com/elixir-grpc/grpc/commit/61a719… | patch |
Impacted products
2 products
| Vendor | Product | Version | |
|---|---|---|---|
| elixir-grpc | grpc_server |
Affected:
1.0.0 , < 1.1.0
(semver)
cpe:2.3:a:elixir-grpc:grpc_server:*:*:*:*:*:*:*:* |
|
| elixir-grpc | grpc_server |
Affected:
0332faae5f5e132314794dbfdae5edecbccea292 , < 61a71948e6e7b95efb27d48e7084c764503cc802
(git)
cpe:2.3:a:elixir-grpc:grpc_server:*:*:*:*:*:*:*:* |
{
"containers": {
"cna": {
"affected": [
{
"collectionURL": "https://repo.hex.pm",
"cpes": [
"cpe:2.3:a:elixir-grpc:grpc_server:*:*:*:*:*:*:*:*"
],
"defaultStatus": "unaffected",
"modules": [
"grpc_stream_h"
],
"packageName": "grpc_server",
"packageURL": "pkg:hex/grpc_server",
"product": "grpc_server",
"programFiles": [
"src/grpc_stream_h.erl"
],
"programRoutines": [
{
"name": "grpc_stream_h:init/3"
}
],
"repo": "https://github.com/elixir-grpc/grpc",
"vendor": "elixir-grpc",
"versions": [
{
"lessThan": "1.1.0",
"status": "affected",
"version": "1.0.0",
"versionType": "semver"
}
]
},
{
"collectionURL": "https://github.com",
"cpes": [
"cpe:2.3:a:elixir-grpc:grpc_server:*:*:*:*:*:*:*:*"
],
"defaultStatus": "unaffected",
"modules": [
"grpc_stream_h"
],
"packageName": "elixir-grpc/grpc",
"packageURL": "pkg:github/elixir-grpc/grpc",
"product": "grpc_server",
"programFiles": [
"grpc_server/src/grpc_stream_h.erl"
],
"programRoutines": [
{
"name": "grpc_stream_h:init/3"
}
],
"repo": "https://github.com/elixir-grpc/grpc",
"vendor": "elixir-grpc",
"versions": [
{
"lessThan": "61a71948e6e7b95efb27d48e7084c764503cc802",
"status": "affected",
"version": "0332faae5f5e132314794dbfdae5edecbccea292",
"versionType": "git"
}
]
}
],
"configurations": [
{
"lang": "en",
"supportingMedia": [
{
"base64": false,
"type": "text/html",
"value": "\u003cp\u003eCalls from more than one caller must share one HTTP/2 connection to the server, for example through a proxy or a shared client channel.\u003c/p\u003e"
},
{
"base64": false,
"type": "text/markdown",
"value": "Calls from more than one caller must share one HTTP/2 connection to the server, for example through a proxy or a shared client channel."
}
],
"value": "Calls from more than one caller must share one HTTP/2 connection to the server, for example through a proxy or a shared client channel."
}
],
"cpeApplicability": [
{
"nodes": [
{
"cpeMatch": [
{
"criteria": "cpe:2.3:a:elixir-grpc:grpc_server:*:*:*:*:*:*:*:*",
"versionEndExcluding": "1.1.0",
"versionStartIncluding": "1.0.0",
"vulnerable": true
}
],
"negate": false,
"operator": "OR"
}
],
"operator": "AND"
}
],
"credits": [
{
"lang": "en",
"type": "reporter",
"value": "Aaron Lovi"
},
{
"lang": "en",
"type": "finder",
"value": "Aaron Lovi"
},
{
"lang": "en",
"type": "remediation developer",
"value": "Paulo Valente"
},
{
"lang": "en",
"type": "coordinator",
"value": "Jonatan M\u00e4nnchen / EEF"
}
],
"dateAssigned": "2026-11-12T20:33:14.000Z",
"descriptions": [
{
"lang": "en",
"supportingMedia": [
{
"base64": false,
"type": "text/html",
"value": "\u003cp\u003eMissing Release of Resource after Effective Lifetime vulnerability in elixir-grpc grpc_server allows a client to stall all later calls on a shared HTTP/2 connection via calls that the server refuses while their DATA frames are in flight.\u003c/p\u003e\n\u003cp\u003eThe stream handler \u003ccode\u003egrpc_stream_h\u003c/code\u003e (\u003ccode\u003egrpc_server/src/grpc_stream_h.erl\u003c/code\u003e) requests flow from Cowboy only for request data that has arrived. When the server refuses a call early, for example because the body exceeds \u003ccode\u003emax_body_size\u003c/code\u003e or the method is not implemented, Cowboy drops the DATA frames still in flight for that stream. Their bytes are not returned to the connection flow-control window. After enough refused calls the window is empty. No call on that connection can then send a request body, and each call waits until its deadline. Other connections are not affected.\u003c/p\u003e\n\u003cp\u003eThis issue affects grpc_server: from 1.0.0 before 1.1.0.\u003c/p\u003e"
},
{
"base64": false,
"type": "text/markdown",
"value": "Missing Release of Resource after Effective Lifetime vulnerability in elixir-grpc grpc_server allows a client to stall all later calls on a shared HTTP/2 connection via calls that the server refuses while their DATA frames are in flight.\n\nThe stream handler `grpc_stream_h` (`grpc_server/src/grpc_stream_h.erl`) requests flow from Cowboy only for request data that has arrived. When the server refuses a call early, for example because the body exceeds `max_body_size` or the method is not implemented, Cowboy drops the DATA frames still in flight for that stream. Their bytes are not returned to the connection flow-control window. After enough refused calls the window is empty. No call on that connection can then send a request body, and each call waits until its deadline. Other connections are not affected.\n\nThis issue affects grpc_server: from 1.0.0 before 1.1.0."
}
],
"value": "Missing Release of Resource after Effective Lifetime vulnerability in elixir-grpc grpc_server allows a client to stall all later calls on a shared HTTP/2 connection via calls that the server refuses while their DATA frames are in flight.\n\nThe stream handler grpc_stream_h (grpc_server/src/grpc_stream_h.erl) requests flow from Cowboy only for request data that has arrived. When the server refuses a call early, for example because the body exceeds max_body_size or the method is not implemented, Cowboy drops the DATA frames still in flight for that stream. Their bytes are not returned to the connection flow-control window. After enough refused calls the window is empty. No call on that connection can then send a request body, and each call waits until its deadline. Other connections are not affected.\n\nThis issue affects grpc_server: from 1.0.0 before 1.1.0."
}
],
"impacts": [
{
"capecId": "CAPEC-131",
"descriptions": [
{
"lang": "en",
"supportingMedia": [
{
"base64": false,
"type": "text/html",
"value": "\u003cp\u003eA client whose calls share an HTTP/2 connection with other callers can make all later calls on that connection wait until their deadlines. Calls on other connections are served normally.\u003c/p\u003e"
},
{
"base64": false,
"type": "text/markdown",
"value": "A client whose calls share an HTTP/2 connection with other callers can make all later calls on that connection wait until their deadlines. Calls on other connections are served normally."
}
],
"value": "A client whose calls share an HTTP/2 connection with other callers can make all later calls on that connection wait until their deadlines. Calls on other connections are served normally."
}
]
}
],
"metrics": [
{
"cvssV4_0": {
"Automatable": "NOT_DEFINED",
"Recovery": "NOT_DEFINED",
"Safety": "NOT_DEFINED",
"attackComplexity": "LOW",
"attackRequirements": "PRESENT",
"attackVector": "NETWORK",
"baseScore": 6.3,
"baseSeverity": "MEDIUM",
"privilegesRequired": "NONE",
"providerUrgency": "NOT_DEFINED",
"subAvailabilityImpact": "NONE",
"subConfidentialityImpact": "NONE",
"subIntegrityImpact": "NONE",
"userInteraction": "NONE",
"valueDensity": "NOT_DEFINED",
"vectorString": "CVSS:4.0/AV:N/AC:L/AT:P/PR:N/UI:N/VC:N/VI:N/VA:L/SC:N/SI:N/SA:N",
"version": "4.0",
"vulnAvailabilityImpact": "LOW",
"vulnConfidentialityImpact": "NONE",
"vulnIntegrityImpact": "NONE",
"vulnerabilityResponseEffort": "NOT_DEFINED"
},
"format": "CVSS",
"scenarios": [
{
"lang": "en",
"value": "GENERAL"
}
]
}
],
"problemTypes": [
{
"descriptions": [
{
"cweId": "CWE-772",
"description": "CWE-772 Missing Release of Resource after Effective Lifetime",
"lang": "en",
"type": "CWE"
}
]
}
],
"providerMetadata": {
"dateUpdated": "2026-11-13T15:40:16.288Z",
"orgId": "6b3ad84c-e1a6-4bf7-a703-f496b71e49db",
"shortName": "EEF"
},
"references": [
{
"name": "Refused calls drain the HTTP/2 connection flow-control window in elixir-grpc grpc_server",
"tags": [
"vendor-advisory",
"related"
],
"url": "https://github.com/elixir-grpc/grpc/security/advisories/GHSA-7x9q-vvph-rjcr"
},
{
"name": "EEF CNA record for CVE-2026-123635",
"tags": [
"related"
],
"url": "https://cna.erlef.org/cves/CVE-2026-123635.html"
},
{
"name": "OSV record EEF-CVE-2026-123635",
"tags": [
"related"
],
"url": "https://osv.dev/vulnerability/EEF-CVE-2026-123635"
},
{
"name": "Introducing commit 0332faa in elixir-grpc/grpc",
"tags": [
"related"
],
"url": "https://github.com/elixir-grpc/grpc/commit/0332faae5f5e132314794dbfdae5edecbccea292"
},
{
"name": "Fix commit 61a7194 in elixir-grpc/grpc",
"tags": [
"patch"
],
"url": "https://github.com/elixir-grpc/grpc/commit/61a71948e6e7b95efb27d48e7084c764503cc802"
}
],
"source": {
"discovery": "EXTERNAL"
},
"title": "Refused calls drain the HTTP/2 connection flow-control window in elixir-grpc grpc_server",
"workarounds": [
{
"lang": "en",
"supportingMedia": [
{
"base64": false,
"type": "text/html",
"value": "\u003cp\u003ePut a Cowboy stream handler in front of \u003ccode\u003egrpc_stream_h\u003c/code\u003e with the adapter option \u003ccode\u003estream_handlers: [MyHandler, :grpc_stream_h]\u003c/code\u003e. In \u003ccode\u003einit/3\u003c/code\u003e, the handler calls \u003ccode\u003e:cowboy_stream.init/3\u003c/code\u003e and adds a \u003ccode\u003e{:flow, size}\u003c/code\u003e command when the request has a body, where \u003ccode\u003esize\u003c/code\u003e is Cowboy\u0027s \u003ccode\u003emax_frame_size_received\u003c/code\u003e (16384 by default). It passes all other callbacks to \u003ccode\u003e:cowboy_stream\u003c/code\u003e.\u003c/p\u003e"
},
{
"base64": false,
"type": "text/markdown",
"value": "Put a Cowboy stream handler in front of `grpc_stream_h` with the adapter option `stream_handlers: [MyHandler, :grpc_stream_h]`. In `init/3`, the handler calls `:cowboy_stream.init/3` and adds a `{:flow, size}` command when the request has a body, where `size` is Cowboy\u0027s `max_frame_size_received` (16384 by default). It passes all other callbacks to `:cowboy_stream`."
}
],
"value": "Put a Cowboy stream handler in front of grpc_stream_h with the adapter option stream_handlers: [MyHandler, :grpc_stream_h]. In init/3, the handler calls :cowboy_stream.init/3 and adds a {:flow, size} command when the request has a body, where size is Cowboy\u0027s max_frame_size_received (16384 by default). It passes all other callbacks to :cowboy_stream."
}
],
"x_proofOfConcept": [
{
"lang": "en",
"supportingMedia": [
{
"base64": false,
"type": "text/html",
"value": "\u003col\u003e\n\u003cli\u003eStart a gRPC server with a small \u003ccode\u003emax_body_size\u003c/code\u003e, for example 1000 bytes.\u003c/li\u003e\n\u003cli\u003eOpen one HTTP/2 connection with a client that tracks the connection window.\u003c/li\u003e\n\u003cli\u003eSend a unary call with a body above the limit and wait for the \u003ccode\u003eRESOURCE_EXHAUSTED\u003c/code\u003e reply.\u003c/li\u003e\n\u003cli\u003eSend more DATA frames on the refused stream, up to what the stream and connection windows allow.\u003c/li\u003e\n\u003cli\u003eRepeat steps 3 and 4 on new streams until the connection window is 0.\u003c/li\u003e\n\u003cli\u003eSend a small valid unary call on the same connection. Observe that its body cannot be sent and that the call ends at its deadline.\u003c/li\u003e\n\u003c/ol\u003e"
},
{
"base64": false,
"type": "text/markdown",
"value": "1. Start a gRPC server with a small `max_body_size`, for example 1000 bytes.\n2. Open one HTTP/2 connection with a client that tracks the connection window.\n3. Send a unary call with a body above the limit and wait for the `RESOURCE_EXHAUSTED` reply.\n4. Send more DATA frames on the refused stream, up to what the stream and connection windows allow.\n5. Repeat steps 3 and 4 on new streams until the connection window is 0.\n6. Send a small valid unary call on the same connection. Observe that its body cannot be sent and that the call ends at its deadline."
}
],
"value": "* Start a gRPC server with a small max_body_size, for example 1000 bytes.\n* Open one HTTP/2 connection with a client that tracks the connection window.\n* Send a unary call with a body above the limit and wait for the RESOURCE_EXHAUSTED reply.\n* Send more DATA frames on the refused stream, up to what the stream and connection windows allow.\n* Repeat steps 3 and 4 on new streams until the connection window is 0.\n* Send a small valid unary call on the same connection. Observe that its body cannot be sent and that the call ends at its deadline."
}
],
"x_technicalAnalysis": [
{
"lang": "en",
"supportingMedia": [
{
"base64": false,
"type": "text/html",
"value": "\u003cp\u003e\u003cstrong\u003e1. Flow is requested only for data that arrived\u003c/strong\u003e\u003c/p\u003e\n\u003cp\u003eCowboy increases the HTTP/2 connection window only when a stream handler returns a \u003ccode\u003e{flow, N}\u003c/code\u003e command. \u003ccode\u003egrpc_stream_h\u003c/code\u003e returns that command only for request data it has received.\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003e2. A refused call leaves data uncredited\u003c/strong\u003e\u003c/p\u003e\n\u003cp\u003eWhen the server refuses a call, the stream stops. The client can still have DATA frames in flight for it. Cowboy drops those frames without running the handler, so no flow is requested for their bytes and no connection-level \u003ccode\u003eWINDOW_UPDATE\u003c/code\u003e is sent.\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003e3. The window empties\u003c/strong\u003e\u003c/p\u003e\n\u003cp\u003eEach refused call with DATA in flight reduces the connection window for good. With the default 65,535-byte window, the window was empty after two refused calls in a test against v1.0.6. An empty window stops all request data, so no handler requests flow again and the window stays empty.\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003e4. Result\u003c/strong\u003e\u003c/p\u003e\n\u003cp\u003eEvery later call on the connection that has a request body waits until its deadline. A new connection works normally.\u003c/p\u003e"
},
{
"base64": false,
"type": "text/markdown",
"value": "**1. Flow is requested only for data that arrived**\n\nCowboy increases the HTTP/2 connection window only when a stream handler returns a `{flow, N}` command. `grpc_stream_h` returns that command only for request data it has received.\n\n**2. A refused call leaves data uncredited**\n\nWhen the server refuses a call, the stream stops. The client can still have DATA frames in flight for it. Cowboy drops those frames without running the handler, so no flow is requested for their bytes and no connection-level `WINDOW_UPDATE` is sent.\n\n**3. The window empties**\n\nEach refused call with DATA in flight reduces the connection window for good. With the default 65,535-byte window, the window was empty after two refused calls in a test against v1.0.6. An empty window stops all request data, so no handler requests flow again and the window stays empty.\n\n**4. Result**\n\nEvery later call on the connection that has a request body waits until its deadline. A new connection works normally."
}
],
"value": "1. Flow is requested only for data that arrived\n\nCowboy increases the HTTP/2 connection window only when a stream handler returns a {flow, N} command. grpc_stream_h returns that command only for request data it has received.\n\n2. A refused call leaves data uncredited\n\nWhen the server refuses a call, the stream stops. The client can still have DATA frames in flight for it. Cowboy drops those frames without running the handler, so no flow is requested for their bytes and no connection-level WINDOW_UPDATE is sent.\n\n3. The window empties\n\nEach refused call with DATA in flight reduces the connection window for good. With the default 65,535-byte window, the window was empty after two refused calls in a test against v1.0.6. An empty window stops all request data, so no handler requests flow again and the window stays empty.\n\n4. Result\n\nEvery later call on the connection that has a request body waits until its deadline. A new connection works normally."
}
]
}
},
"cveMetadata": {
"assignerOrgId": "6b3ad84c-e1a6-4bf7-a703-f496b71e49db",
"assignerShortName": "EEF",
"cveId": "CVE-2026-123635",
"datePublished": "2026-11-13T15:40:16.288Z",
"dateReserved": "2026-11-09T22:27:54.995Z",
"dateUpdated": "2026-11-13T15:40:16.288Z",
"state": "PUBLISHED"
},
"dataType": "CVE_RECORD",
"dataVersion": "5.2"
}
CVE-2026-119446 (GCVE-0-2026-119446)
Vulnerability from cvelistv5 – Published: 2026-11-10 10:44 – Updated: 2026-11-12 17:48
VLAI
EPSS
VEX
Title
Session fixation in the CLI login flow allows an unauthenticated attacker to obtain a user's API token
Summary
Session Fixation vulnerability in the CLI login flow in nerves-hub nerves_hub_web allows an unauthenticated attacker to obtain an API token for another user's account via a crafted login link. Starting a CLI login with POST /api/auth/cli_session needs no authentication and returns a polling token, a confirmation URL that carries that token, and a confirmation code. When a signed-in user opens that URL and clicks Confirm, NervesHub.Accounts.verify_cli_session_token/2 creates an API token for the user's account and stores it on the login, and the next poll of GET /api/auth/cli_session/<token> returns it to whoever started the login.
The confirmation code is only displayed and is never compared with anything, so it cannot show that the person confirming is the one who started the login. The confirm form carries a CSRF token and the page cannot be framed, so the attacker has to persuade the user to open the link and click the button themselves.
This issue affects nerves_hub: from 2.5.0 before 2.5.3.
Severity
SSVC
Exploitation: poc
Automatable: no
Technical Impact: total
CISA Coordinator · CISA-ADP (v2.0.3)
Decision recorded 2026-11-12 17:48 UTC
CWE
- CWE-384 - Session Fixation
Assigner
References
5 references
| URL | Tags |
|---|---|
| https://github.com/nerves-hub/nerves_hub_web/secu… | vendor-advisoryrelated |
| https://cna.erlef.org/cves/CVE-2026-119446.html | related |
| https://osv.dev/vulnerability/EEF-CVE-2026-119446 | related |
| https://github.com/nerves-hub/nerves_hub_web/comm… | related |
| https://github.com/nerves-hub/nerves_hub_web/comm… | patch |
Impacted products
3 products
| Vendor | Product | Version | |
|---|---|---|---|
| nerves-hub | nerves_hub_web |
Affected:
2.5.0 , < 2.5.3
(other)
cpe:2.3:a:nerves-hub:nerves_hub_web:*:*:*:*:*:*:*:* |
|
| nerves-hub | nerves_hub_web |
Affected:
2.5.0 , < 2.5.3
(semver)
cpe:2.3:a:nerves-hub:nerves_hub_web:*:*:*:*:*:*:*:* |
|
| nerves-hub | nerves_hub_web |
Affected:
e6bf11a812d7aa8b69d854ba8ef02d65516f5564 , < 3131f1701194b581ab4b3e5e9d81194fc9c83508
(git)
cpe:2.3:a:nerves-hub:nerves_hub_web:*:*:*:*:*:*:*:* |
Date Public
2026-11-10 10:44
{
"containers": {
"adp": [
{
"metrics": [
{
"other": {
"content": {
"id": "CVE-2026-119446",
"options": [
{
"Exploitation": "poc"
},
{
"Automatable": "no"
},
{
"Technical Impact": "total"
}
],
"role": "CISA Coordinator",
"timestamp": "2026-11-12T17:48:38.519155Z",
"version": "2.0.3"
},
"type": "ssvc"
}
}
],
"providerMetadata": {
"dateUpdated": "2026-11-12T17:48:56.966Z",
"orgId": "134c704f-9b21-4f2e-91b3-4a467353bcc0",
"shortName": "CISA-ADP"
},
"references": [
{
"tags": [
"exploit"
],
"url": "https://github.com/nerves-hub/nerves_hub_web/security/advisories/GHSA-9vr9-463q-46qp"
}
],
"title": "CISA ADP Vulnrichment"
}
],
"cna": {
"affected": [
{
"collectionURL": "https://ghcr.io",
"cpes": [
"cpe:2.3:a:nerves-hub:nerves_hub_web:*:*:*:*:*:*:*:*"
],
"defaultStatus": "unaffected",
"modules": [
"\u0027Elixir.NervesHubWeb.API.UserController\u0027",
"\u0027Elixir.NervesHubWeb.SessionController\u0027",
"\u0027Elixir.NervesHub.Accounts\u0027",
"\u0027Elixir.NervesHubWeb.Router\u0027"
],
"packageName": "nerves-hub/nerves-hub",
"packageURL": "pkg:oci/nerves-hub?repository_url=ghcr.io%2Fnerves-hub%2Fnerves-hub",
"product": "nerves_hub_web",
"programFiles": [
"lib/nerves_hub_web/controllers/api/user_controller.ex",
"lib/nerves_hub_web/controllers/session_controller.ex",
"lib/nerves_hub/accounts.ex",
"lib/nerves_hub_web/router.ex"
],
"programRoutines": [
{
"name": "\u0027Elixir.NervesHubWeb.API.UserController\u0027:cli_session/2"
},
{
"name": "\u0027Elixir.NervesHubWeb.API.UserController\u0027:check_cli_session/2"
},
{
"name": "\u0027Elixir.NervesHubWeb.SessionController\u0027:cli/2"
},
{
"name": "\u0027Elixir.NervesHubWeb.SessionController\u0027:cli_confirm/2"
},
{
"name": "\u0027Elixir.NervesHub.Accounts\u0027:generate_cli_session_token/1"
},
{
"name": "\u0027Elixir.NervesHub.Accounts\u0027:cli_session_waiting?/2"
},
{
"name": "\u0027Elixir.NervesHub.Accounts\u0027:verify_cli_session_token/2"
}
],
"vendor": "nerves-hub",
"versions": [
{
"lessThan": "2.5.3",
"status": "affected",
"version": "2.5.0",
"versionType": "other"
}
]
},
{
"cpes": [
"cpe:2.3:a:nerves-hub:nerves_hub_web:*:*:*:*:*:*:*:*"
],
"defaultStatus": "unaffected",
"modules": [
"\u0027Elixir.NervesHubWeb.API.UserController\u0027",
"\u0027Elixir.NervesHubWeb.SessionController\u0027",
"\u0027Elixir.NervesHub.Accounts\u0027",
"\u0027Elixir.NervesHubWeb.Router\u0027"
],
"packageName": "nerves_hub",
"packageURL": "pkg:otp/nerves_hub?repository_url=https:%2F%2Fgithub.com%2Fnerves-hub%2Fnerves_hub_web\u0026vcs_url=git%2Bhttps:%2F%2Fgithub.com%2Fnerves-hub%2Fnerves_hub_web.git",
"product": "nerves_hub_web",
"programFiles": [
"lib/nerves_hub_web/controllers/api/user_controller.ex",
"lib/nerves_hub_web/controllers/session_controller.ex",
"lib/nerves_hub/accounts.ex",
"lib/nerves_hub_web/router.ex"
],
"programRoutines": [
{
"name": "\u0027Elixir.NervesHubWeb.API.UserController\u0027:cli_session/2"
},
{
"name": "\u0027Elixir.NervesHubWeb.API.UserController\u0027:check_cli_session/2"
},
{
"name": "\u0027Elixir.NervesHubWeb.SessionController\u0027:cli/2"
},
{
"name": "\u0027Elixir.NervesHubWeb.SessionController\u0027:cli_confirm/2"
},
{
"name": "\u0027Elixir.NervesHub.Accounts\u0027:generate_cli_session_token/1"
},
{
"name": "\u0027Elixir.NervesHub.Accounts\u0027:cli_session_waiting?/2"
},
{
"name": "\u0027Elixir.NervesHub.Accounts\u0027:verify_cli_session_token/2"
}
],
"repo": "https://github.com/nerves-hub/nerves_hub_web",
"vendor": "nerves-hub",
"versions": [
{
"lessThan": "2.5.3",
"status": "affected",
"version": "2.5.0",
"versionType": "semver"
}
]
},
{
"collectionURL": "https://github.com",
"cpes": [
"cpe:2.3:a:nerves-hub:nerves_hub_web:*:*:*:*:*:*:*:*"
],
"defaultStatus": "unaffected",
"modules": [
"\u0027Elixir.NervesHubWeb.API.UserController\u0027",
"\u0027Elixir.NervesHubWeb.SessionController\u0027",
"\u0027Elixir.NervesHub.Accounts\u0027",
"\u0027Elixir.NervesHubWeb.Router\u0027"
],
"packageName": "nerves-hub/nerves_hub_web",
"packageURL": "pkg:github/nerves-hub/nerves_hub_web",
"product": "nerves_hub_web",
"programFiles": [
"lib/nerves_hub_web/controllers/api/user_controller.ex",
"lib/nerves_hub_web/controllers/session_controller.ex",
"lib/nerves_hub/accounts.ex",
"lib/nerves_hub_web/router.ex"
],
"programRoutines": [
{
"name": "\u0027Elixir.NervesHubWeb.API.UserController\u0027:cli_session/2"
},
{
"name": "\u0027Elixir.NervesHubWeb.API.UserController\u0027:check_cli_session/2"
},
{
"name": "\u0027Elixir.NervesHubWeb.SessionController\u0027:cli/2"
},
{
"name": "\u0027Elixir.NervesHubWeb.SessionController\u0027:cli_confirm/2"
},
{
"name": "\u0027Elixir.NervesHub.Accounts\u0027:generate_cli_session_token/1"
},
{
"name": "\u0027Elixir.NervesHub.Accounts\u0027:cli_session_waiting?/2"
},
{
"name": "\u0027Elixir.NervesHub.Accounts\u0027:verify_cli_session_token/2"
}
],
"repo": "https://github.com/nerves-hub/nerves_hub_web",
"vendor": "nerves-hub",
"versions": [
{
"lessThan": "3131f1701194b581ab4b3e5e9d81194fc9c83508",
"status": "affected",
"version": "e6bf11a812d7aa8b69d854ba8ef02d65516f5564",
"versionType": "git"
}
]
}
],
"cpeApplicability": [
{
"nodes": [
{
"cpeMatch": [
{
"criteria": "cpe:2.3:a:nerves-hub:nerves_hub_web:*:*:*:*:*:*:*:*",
"versionEndExcluding": "2.5.3",
"versionStartIncluding": "2.5.0",
"vulnerable": true
}
],
"negate": false,
"operator": "OR"
}
],
"operator": "AND"
}
],
"credits": [
{
"lang": "en",
"type": "finder",
"value": "Amr Kadry"
},
{
"lang": "en",
"type": "reporter",
"value": "Amr Kadry"
},
{
"lang": "en",
"type": "remediation developer",
"value": "Josh Kalderimis"
},
{
"lang": "en",
"type": "coordinator",
"value": "Jonatan M\u00e4nnchen / EEF"
}
],
"dateAssigned": "2026-11-09T22:12:23.000Z",
"datePublic": "2026-11-10T10:44:15.000Z",
"descriptions": [
{
"lang": "en",
"supportingMedia": [
{
"base64": false,
"type": "text/html",
"value": "\u003cp\u003eSession Fixation vulnerability in the CLI login flow in nerves-hub nerves_hub_web allows an unauthenticated attacker to obtain an API token for another user\u0027s account via a crafted login link. Starting a CLI login with \u003ccode\u003ePOST /api/auth/cli_session\u003c/code\u003e needs no authentication and returns a polling token, a confirmation URL that carries that token, and a confirmation code. When a signed-in user opens that URL and clicks Confirm, \u003ccode\u003eNervesHub.Accounts.verify_cli_session_token/2\u003c/code\u003e creates an API token for the user\u0027s account and stores it on the login, and the next poll of \u003ccode\u003eGET /api/auth/cli_session/\u0026lt;token\u0026gt;\u003c/code\u003e returns it to whoever started the login.\u003c/p\u003e\n\u003cp\u003eThe confirmation code is only displayed and is never compared with anything, so it cannot show that the person confirming is the one who started the login. The confirm form carries a CSRF token and the page cannot be framed, so the attacker has to persuade the user to open the link and click the button themselves.\u003c/p\u003e\n\u003cp\u003eThis issue affects nerves_hub: from 2.5.0 before 2.5.3.\u003c/p\u003e"
},
{
"base64": false,
"type": "text/markdown",
"value": "Session Fixation vulnerability in the CLI login flow in nerves-hub nerves_hub_web allows an unauthenticated attacker to obtain an API token for another user\u0027s account via a crafted login link. Starting a CLI login with `POST /api/auth/cli_session` needs no authentication and returns a polling token, a confirmation URL that carries that token, and a confirmation code. When a signed-in user opens that URL and clicks Confirm, `NervesHub.Accounts.verify_cli_session_token/2` creates an API token for the user\u0027s account and stores it on the login, and the next poll of `GET /api/auth/cli_session/\u003ctoken\u003e` returns it to whoever started the login.\n\nThe confirmation code is only displayed and is never compared with anything, so it cannot show that the person confirming is the one who started the login. The confirm form carries a CSRF token and the page cannot be framed, so the attacker has to persuade the user to open the link and click the button themselves.\n\nThis issue affects nerves_hub: from 2.5.0 before 2.5.3."
}
],
"value": "Session Fixation vulnerability in the CLI login flow in nerves-hub nerves_hub_web allows an unauthenticated attacker to obtain an API token for another user\u0027s account via a crafted login link. Starting a CLI login with POST /api/auth/cli_session needs no authentication and returns a polling token, a confirmation URL that carries that token, and a confirmation code. When a signed-in user opens that URL and clicks Confirm, NervesHub.Accounts.verify_cli_session_token/2 creates an API token for the user\u0027s account and stores it on the login, and the next poll of GET /api/auth/cli_session/\u003ctoken\u003e returns it to whoever started the login.\n\nThe confirmation code is only displayed and is never compared with anything, so it cannot show that the person confirming is the one who started the login. The confirm form carries a CSRF token and the page cannot be framed, so the attacker has to persuade the user to open the link and click the button themselves.\n\nThis issue affects nerves_hub: from 2.5.0 before 2.5.3."
}
],
"impacts": [
{
"capecId": "CAPEC-61",
"descriptions": [
{
"lang": "en",
"supportingMedia": [
{
"base64": false,
"type": "text/html",
"value": "\u003cp\u003eAn attacker with no account obtains an API token for any NervesHub user they can persuade to open a link and click Confirm. The token acts as that user through the API in every organization they belong to, at their role, until the user deletes it on the account page. Signing out of the web does not revoke it.\u003c/p\u003e"
},
{
"base64": false,
"type": "text/markdown",
"value": "An attacker with no account obtains an API token for any NervesHub user they can persuade to open a link and click Confirm. The token acts as that user through the API in every organization they belong to, at their role, until the user deletes it on the account page. Signing out of the web does not revoke it."
}
],
"value": "An attacker with no account obtains an API token for any NervesHub user they can persuade to open a link and click Confirm. The token acts as that user through the API in every organization they belong to, at their role, until the user deletes it on the account page. Signing out of the web does not revoke it."
}
]
}
],
"metrics": [
{
"cvssV4_0": {
"Automatable": "NOT_DEFINED",
"Recovery": "NOT_DEFINED",
"Safety": "NOT_DEFINED",
"attackComplexity": "LOW",
"attackRequirements": "NONE",
"attackVector": "NETWORK",
"baseScore": 9.4,
"baseSeverity": "CRITICAL",
"privilegesRequired": "NONE",
"providerUrgency": "NOT_DEFINED",
"subAvailabilityImpact": "HIGH",
"subConfidentialityImpact": "HIGH",
"subIntegrityImpact": "HIGH",
"userInteraction": "ACTIVE",
"valueDensity": "NOT_DEFINED",
"vectorString": "CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:A/VC:H/VI:H/VA:H/SC:H/SI:H/SA:H",
"version": "4.0",
"vulnAvailabilityImpact": "HIGH",
"vulnConfidentialityImpact": "HIGH",
"vulnIntegrityImpact": "HIGH",
"vulnerabilityResponseEffort": "NOT_DEFINED"
},
"format": "CVSS",
"scenarios": [
{
"lang": "en",
"value": "GENERAL"
}
]
}
],
"problemTypes": [
{
"descriptions": [
{
"cweId": "CWE-384",
"description": "CWE-384 Session Fixation",
"lang": "en",
"type": "CWE"
}
]
}
],
"providerMetadata": {
"dateUpdated": "2026-11-10T17:11:05.623Z",
"orgId": "6b3ad84c-e1a6-4bf7-a703-f496b71e49db",
"shortName": "EEF"
},
"references": [
{
"name": "CLI login links give an API token to whoever sent them",
"tags": [
"vendor-advisory",
"related"
],
"url": "https://github.com/nerves-hub/nerves_hub_web/security/advisories/GHSA-9vr9-463q-46qp"
},
{
"name": "EEF CNA record for CVE-2026-119446",
"tags": [
"related"
],
"url": "https://cna.erlef.org/cves/CVE-2026-119446.html"
},
{
"name": "OSV record EEF-CVE-2026-119446",
"tags": [
"related"
],
"url": "https://osv.dev/vulnerability/EEF-CVE-2026-119446"
},
{
"name": "Introducing commit e6bf11a in nerves-hub/nerves_hub_web",
"tags": [
"related"
],
"url": "https://github.com/nerves-hub/nerves_hub_web/commit/e6bf11a812d7aa8b69d854ba8ef02d65516f5564"
},
{
"name": "Fix commit 3131f17 in nerves-hub/nerves_hub_web",
"tags": [
"patch"
],
"url": "https://github.com/nerves-hub/nerves_hub_web/commit/3131f1701194b581ab4b3e5e9d81194fc9c83508"
}
],
"solutions": [
{
"lang": "en",
"supportingMedia": [
{
"base64": false,
"type": "text/html",
"value": "\u003cp\u003eUpgrading does not revoke tokens already handed out. A token from a CLI login is an ordinary API token with a note the client chose, so one an attacker obtained looks like any other. After upgrading, users should review their API tokens on the account page and delete any they do not recognise.\u003c/p\u003e"
},
{
"base64": false,
"type": "text/markdown",
"value": "Upgrading does not revoke tokens already handed out. A token from a CLI login is an ordinary API token with a note the client chose, so one an attacker obtained looks like any other. After upgrading, users should review their API tokens on the account page and delete any they do not recognise."
}
],
"value": "Upgrading does not revoke tokens already handed out. A token from a CLI login is an ordinary API token with a note the client chose, so one an attacker obtained looks like any other. After upgrading, users should review their API tokens on the account page and delete any they do not recognise."
}
],
"source": {
"discovery": "UNKNOWN"
},
"title": "Session fixation in the CLI login flow allows an unauthenticated attacker to obtain a user\u0027s API token",
"workarounds": [
{
"lang": "en",
"supportingMedia": [
{
"base64": false,
"type": "text/html",
"value": "\u003cp\u003eBlock \u003ccode\u003ePOST /api/auth/cli_session\u003c/code\u003e at the reverse proxy until the upgrade. It is the only entry point that creates a CLI login, so no link can be produced while it is blocked. CLIs can still sign in with an email and password (\u003ccode\u003enh user auth\u003c/code\u003e, \u003ccode\u003ePOST /api/users/auth\u003c/code\u003e), or use an API token created on the account page.\u003c/p\u003e"
},
{
"base64": false,
"type": "text/markdown",
"value": "Block `POST /api/auth/cli_session` at the reverse proxy until the upgrade. It is the only entry point that creates a CLI login, so no link can be produced while it is blocked. CLIs can still sign in with an email and password (`nh user auth`, `POST /api/users/auth`), or use an API token created on the account page."
}
],
"value": "Block POST /api/auth/cli_session at the reverse proxy until the upgrade. It is the only entry point that creates a CLI login, so no link can be produced while it is blocked. CLIs can still sign in with an email and password (nh user auth, POST /api/users/auth), or use an API token created on the account page."
}
],
"x_proofOfConcept": [
{
"lang": "en",
"supportingMedia": [
{
"base64": false,
"type": "text/html",
"value": "\u003col\u003e\n\u003cli\u003eWith no account, run \u003ccode\u003ecurl -X POST https://\u0026lt;host\u0026gt;/api/auth/cli_session\u003c/code\u003e. Note \u003ccode\u003etoken\u003c/code\u003e, \u003ccode\u003eurl\u003c/code\u003e and \u003ccode\u003econfirmation_code\u003c/code\u003e from the response.\u003c/li\u003e\n\u003cli\u003eSend \u003ccode\u003eurl\u003c/code\u003e to someone with a NervesHub account, with a message such as \"Please verify your NervesHub CLI access. Your code is \u003ccode\u003e\u0026lt;confirmation_code\u0026gt;\u003c/code\u003e.\"\u003c/li\u003e\n\u003cli\u003eThey open it while signed in, see the code they were told to expect, and click \"Confirm login\".\u003c/li\u003e\n\u003cli\u003e\u003ccode\u003ecurl https://\u0026lt;host\u0026gt;/api/auth/cli_session/\u0026lt;token\u0026gt;\u003c/code\u003e returns \u003ccode\u003e\"status\": \"ready\"\u003c/code\u003e and a \u003ccode\u003euser_token\u003c/code\u003e.\u003c/li\u003e\n\u003cli\u003eSend API requests with \u003ccode\u003eAuthorization: token \u0026lt;user_token\u0026gt;\u003c/code\u003e. They run as the person who confirmed.\u003c/li\u003e\n\u003c/ol\u003e"
},
{
"base64": false,
"type": "text/markdown",
"value": "1. With no account, run `curl -X POST https://\u003chost\u003e/api/auth/cli_session`. Note `token`, `url` and `confirmation_code` from the response.\n2. Send `url` to someone with a NervesHub account, with a message such as \"Please verify your NervesHub CLI access. Your code is `\u003cconfirmation_code\u003e`.\"\n3. They open it while signed in, see the code they were told to expect, and click \"Confirm login\".\n4. `curl https://\u003chost\u003e/api/auth/cli_session/\u003ctoken\u003e` returns `\"status\": \"ready\"` and a `user_token`.\n5. Send API requests with `Authorization: token \u003cuser_token\u003e`. They run as the person who confirmed."
}
],
"value": "* With no account, run curl -X POST https://\u003chost\u003e/api/auth/cli_session. Note token, url and confirmation_code from the response.\n* Send url to someone with a NervesHub account, with a message such as \"Please verify your NervesHub CLI access. Your code is \u003cconfirmation_code\u003e.\"\n* They open it while signed in, see the code they were told to expect, and click \"Confirm login\".\n* curl https://\u003chost\u003e/api/auth/cli_session/\u003ctoken\u003e returns \"status\": \"ready\" and a user_token.\n* Send API requests with Authorization: token \u003cuser_token\u003e. They run as the person who confirmed."
}
],
"x_technicalAnalysis": [
{
"lang": "en",
"supportingMedia": [
{
"base64": false,
"type": "text/html",
"value": "\u003cp\u003e\u003cstrong\u003e1. Starting a login.\u003c/strong\u003e \u003ccode\u003enh user login\u003c/code\u003e, like any client of this flow, calls \u003ccode\u003ePOST /api/auth/cli_session\u003c/code\u003e, which needs no authentication. \u003ccode\u003eNervesHubWeb.API.UserController.cli_session/2\u003c/code\u003e calls \u003ccode\u003eNervesHub.Accounts.generate_cli_session_token/1\u003c/code\u003e, which stores a waiting login in the CLI session cache under a random UUID token with a six-digit confirmation code, and responds with the token, the code, and the URL \u003ccode\u003e/auth/cli/\u0026lt;token\u0026gt;\u003c/code\u003e.\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003e2. Approving a login.\u003c/strong\u003e \u003ccode\u003eGET /auth/cli/\u0026lt;token\u0026gt;\u003c/code\u003e requires a signed-in web user. \u003ccode\u003eNervesHubWeb.SessionController.cli/2\u003c/code\u003e finds the login by the token from the URL, shows the code, and renders a form that posts back to \u003ccode\u003e/auth/cli/\u0026lt;token\u0026gt;\u003c/code\u003e. The form carries no code field. \u003ccode\u003ecli_confirm/2\u003c/code\u003e passes the signed-in user and the token to \u003ccode\u003eNervesHub.Accounts.verify_cli_session_token/2\u003c/code\u003e, which creates an API token for that user with \u003ccode\u003ecreate_user_api_token/2\u003c/code\u003e and stores it on the login. Nothing compares the confirmation code with anything.\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003e3. Collecting the token.\u003c/strong\u003e \u003ccode\u003eGET /api/auth/cli_session/\u0026lt;token\u0026gt;\u003c/code\u003e, also unauthenticated, returns the stored API token once the login is confirmed. Whoever started the login holds the token from step 1 and can poll until it arrives. The token is an ordinary API token and stays valid until the user deletes it.\u003c/p\u003e"
},
{
"base64": false,
"type": "text/markdown",
"value": "**1. Starting a login.** `nh user login`, like any client of this flow, calls `POST /api/auth/cli_session`, which needs no authentication. `NervesHubWeb.API.UserController.cli_session/2` calls `NervesHub.Accounts.generate_cli_session_token/1`, which stores a waiting login in the CLI session cache under a random UUID token with a six-digit confirmation code, and responds with the token, the code, and the URL `/auth/cli/\u003ctoken\u003e`.\n\n**2. Approving a login.** `GET /auth/cli/\u003ctoken\u003e` requires a signed-in web user. `NervesHubWeb.SessionController.cli/2` finds the login by the token from the URL, shows the code, and renders a form that posts back to `/auth/cli/\u003ctoken\u003e`. The form carries no code field. `cli_confirm/2` passes the signed-in user and the token to `NervesHub.Accounts.verify_cli_session_token/2`, which creates an API token for that user with `create_user_api_token/2` and stores it on the login. Nothing compares the confirmation code with anything.\n\n**3. Collecting the token.** `GET /api/auth/cli_session/\u003ctoken\u003e`, also unauthenticated, returns the stored API token once the login is confirmed. Whoever started the login holds the token from step 1 and can poll until it arrives. The token is an ordinary API token and stays valid until the user deletes it."
}
],
"value": "1. Starting a login. nh user login, like any client of this flow, calls POST /api/auth/cli_session, which needs no authentication. NervesHubWeb.API.UserController.cli_session/2 calls NervesHub.Accounts.generate_cli_session_token/1, which stores a waiting login in the CLI session cache under a random UUID token with a six-digit confirmation code, and responds with the token, the code, and the URL /auth/cli/\u003ctoken\u003e.\n\n2. Approving a login. GET /auth/cli/\u003ctoken\u003e requires a signed-in web user. NervesHubWeb.SessionController.cli/2 finds the login by the token from the URL, shows the code, and renders a form that posts back to /auth/cli/\u003ctoken\u003e. The form carries no code field. cli_confirm/2 passes the signed-in user and the token to NervesHub.Accounts.verify_cli_session_token/2, which creates an API token for that user with create_user_api_token/2 and stores it on the login. Nothing compares the confirmation code with anything.\n\n3. Collecting the token. GET /api/auth/cli_session/\u003ctoken\u003e, also unauthenticated, returns the stored API token once the login is confirmed. Whoever started the login holds the token from step 1 and can poll until it arrives. The token is an ordinary API token and stays valid until the user deletes it."
}
]
}
},
"cveMetadata": {
"assignerOrgId": "6b3ad84c-e1a6-4bf7-a703-f496b71e49db",
"assignerShortName": "EEF",
"cveId": "CVE-2026-119446",
"datePublished": "2026-11-10T10:44:15.336Z",
"dateReserved": "2026-11-07T21:44:37.269Z",
"dateUpdated": "2026-11-12T17:48:56.966Z",
"state": "PUBLISHED"
},
"dataType": "CVE_RECORD",
"dataVersion": "5.2"
}
CVE-2026-120286 (GCVE-0-2026-120286)
Vulnerability from cvelistv5 – Published: 2026-11-10 10:44 – Updated: 2026-11-12 17:47
VLAI
EPSS
VEX
Title
Missing authorization in device and deployment group page events allows view-role members to change devices and deployment groups
Summary
Missing Authorization vulnerability in the device and deployment group pages in nerves-hub nerves_hub_web allows an authenticated organization member with the view role to change devices and deployment groups via LiveView events reserved for the manage role. The event handlers that add and delete device certificates, add and remove tags, overwrite or clear a device's manual location, remove a device from its deployment group, move matched devices into a deployment group or remove unmatched ones, import devices from a CSV, and start, retry or delete firmware delta builds carry out the change without calling authorized!/2, and the pages render the controls to every member.
A view member can add a certificate that lets whoever holds its key connect as the device, delete a certificate so the device can no longer authenticate with it, and change which deployment group, and so which firmware release, a device receives. Everything stays within the organizations the member belongs to.
This issue affects nerves_hub: from 1.0.0 before 2.5.3.
Severity
SSVC
Exploitation: poc
Automatable: no
Technical Impact: partial
CISA Coordinator · CISA-ADP (v2.0.3)
Decision recorded 2026-11-12 17:47 UTC
CWE
- CWE-862 - Missing Authorization
Assigner
References
5 references
| URL | Tags |
|---|---|
| https://github.com/nerves-hub/nerves_hub_web/secu… | vendor-advisoryrelated |
| https://cna.erlef.org/cves/CVE-2026-120286.html | related |
| https://osv.dev/vulnerability/EEF-CVE-2026-120286 | related |
| https://github.com/nerves-hub/nerves_hub_web/comm… | related |
| https://github.com/nerves-hub/nerves_hub_web/comm… | patch |
Impacted products
3 products
| Vendor | Product | Version | |
|---|---|---|---|
| nerves-hub | nerves_hub_web |
Affected:
1.0.0 , < 2.5.3
(other)
cpe:2.3:a:nerves-hub:nerves_hub_web:*:*:*:*:*:*:*:* |
|
| nerves-hub | nerves_hub_web |
Affected:
1.0.0 , < 2.5.3
(semver)
cpe:2.3:a:nerves-hub:nerves_hub_web:*:*:*:*:*:*:*:* |
|
| nerves-hub | nerves_hub_web |
Affected:
7b511764a468bb2c1f33945874faa86af2255c0c , < 34ea112472a460319c1e1dec8ad0ffc37df05ffe
(git)
cpe:2.3:a:nerves-hub:nerves_hub_web:*:*:*:*:*:*:*:* |
Date Public
2026-11-10 10:44
{
"containers": {
"adp": [
{
"metrics": [
{
"other": {
"content": {
"id": "CVE-2026-120286",
"options": [
{
"Exploitation": "poc"
},
{
"Automatable": "no"
},
{
"Technical Impact": "partial"
}
],
"role": "CISA Coordinator",
"timestamp": "2026-11-12T17:47:40.502753Z",
"version": "2.0.3"
},
"type": "ssvc"
}
}
],
"providerMetadata": {
"dateUpdated": "2026-11-12T17:47:59.045Z",
"orgId": "134c704f-9b21-4f2e-91b3-4a467353bcc0",
"shortName": "CISA-ADP"
},
"references": [
{
"tags": [
"exploit"
],
"url": "https://github.com/nerves-hub/nerves_hub_web/security/advisories/GHSA-gxj7-m8x6-r644"
}
],
"title": "CISA ADP Vulnrichment"
}
],
"cna": {
"affected": [
{
"collectionURL": "https://ghcr.io",
"cpes": [
"cpe:2.3:a:nerves-hub:nerves_hub_web:*:*:*:*:*:*:*:*"
],
"defaultStatus": "unaffected",
"modules": [
"\u0027Elixir.NervesHubWeb.Components.DevicePage.SettingsTab\u0027",
"\u0027Elixir.NervesHubWeb.Components.DevicePage.DetailsTab\u0027",
"\u0027Elixir.NervesHubWeb.Live.DeploymentGroups.Show\u0027",
"\u0027Elixir.NervesHubWeb.Components.DeploymentGroupPage.Summary\u0027"
],
"packageName": "nerves-hub/nerves-hub",
"packageURL": "pkg:oci/nerves-hub?repository_url=ghcr.io%2Fnerves-hub%2Fnerves-hub",
"product": "nerves_hub_web",
"programFiles": [
"lib/nerves_hub_web/components/device_page/settings_tab.ex",
"lib/nerves_hub_web/components/device_page/details_tab.ex",
"lib/nerves_hub_web/live/deployment_groups/show.ex",
"lib/nerves_hub_web/components/deployment_group_page/summary.ex"
],
"programRoutines": [
{
"name": "\u0027Elixir.NervesHubWeb.Components.DevicePage.SettingsTab\u0027:hooked_event/3"
},
{
"name": "\u0027Elixir.NervesHubWeb.Components.DevicePage.SettingsTab\u0027:handle_progress/3"
},
{
"name": "\u0027Elixir.NervesHubWeb.Components.DevicePage.DetailsTab\u0027:hooked_event/3"
},
{
"name": "\u0027Elixir.NervesHubWeb.Live.DeploymentGroups.Show\u0027:handle_event/3"
},
{
"name": "\u0027Elixir.NervesHubWeb.Components.DeploymentGroupPage.Summary\u0027:handle_event/3"
},
{
"name": "\u0027Elixir.NervesHubWeb.Components.DeploymentGroupPage.Summary\u0027:handle_progress/3"
}
],
"vendor": "nerves-hub",
"versions": [
{
"lessThan": "2.5.3",
"status": "affected",
"version": "1.0.0",
"versionType": "other"
}
]
},
{
"cpes": [
"cpe:2.3:a:nerves-hub:nerves_hub_web:*:*:*:*:*:*:*:*"
],
"defaultStatus": "unaffected",
"modules": [
"\u0027Elixir.NervesHubWeb.Components.DevicePage.SettingsTab\u0027",
"\u0027Elixir.NervesHubWeb.Components.DevicePage.DetailsTab\u0027",
"\u0027Elixir.NervesHubWeb.Live.DeploymentGroups.Show\u0027",
"\u0027Elixir.NervesHubWeb.Components.DeploymentGroupPage.Summary\u0027"
],
"packageName": "nerves_hub",
"packageURL": "pkg:otp/nerves_hub?repository_url=https:%2F%2Fgithub.com%2Fnerves-hub%2Fnerves_hub_web\u0026vcs_url=git%2Bhttps:%2F%2Fgithub.com%2Fnerves-hub%2Fnerves_hub_web.git",
"product": "nerves_hub_web",
"programFiles": [
"lib/nerves_hub_web/components/device_page/settings_tab.ex",
"lib/nerves_hub_web/components/device_page/details_tab.ex",
"lib/nerves_hub_web/live/deployment_groups/show.ex",
"lib/nerves_hub_web/components/deployment_group_page/summary.ex"
],
"programRoutines": [
{
"name": "\u0027Elixir.NervesHubWeb.Components.DevicePage.SettingsTab\u0027:hooked_event/3"
},
{
"name": "\u0027Elixir.NervesHubWeb.Components.DevicePage.SettingsTab\u0027:handle_progress/3"
},
{
"name": "\u0027Elixir.NervesHubWeb.Components.DevicePage.DetailsTab\u0027:hooked_event/3"
},
{
"name": "\u0027Elixir.NervesHubWeb.Live.DeploymentGroups.Show\u0027:handle_event/3"
},
{
"name": "\u0027Elixir.NervesHubWeb.Components.DeploymentGroupPage.Summary\u0027:handle_event/3"
},
{
"name": "\u0027Elixir.NervesHubWeb.Components.DeploymentGroupPage.Summary\u0027:handle_progress/3"
}
],
"repo": "https://github.com/nerves-hub/nerves_hub_web",
"vendor": "nerves-hub",
"versions": [
{
"lessThan": "2.5.3",
"status": "affected",
"version": "1.0.0",
"versionType": "semver"
}
]
},
{
"collectionURL": "https://github.com",
"cpes": [
"cpe:2.3:a:nerves-hub:nerves_hub_web:*:*:*:*:*:*:*:*"
],
"defaultStatus": "unaffected",
"modules": [
"\u0027Elixir.NervesHubWeb.Components.DevicePage.SettingsTab\u0027",
"\u0027Elixir.NervesHubWeb.Components.DevicePage.DetailsTab\u0027",
"\u0027Elixir.NervesHubWeb.Live.DeploymentGroups.Show\u0027",
"\u0027Elixir.NervesHubWeb.Components.DeploymentGroupPage.Summary\u0027"
],
"packageName": "nerves-hub/nerves_hub_web",
"packageURL": "pkg:github/nerves-hub/nerves_hub_web",
"product": "nerves_hub_web",
"programFiles": [
"lib/nerves_hub_web/components/device_page/settings_tab.ex",
"lib/nerves_hub_web/components/device_page/details_tab.ex",
"lib/nerves_hub_web/live/deployment_groups/show.ex",
"lib/nerves_hub_web/components/deployment_group_page/summary.ex"
],
"programRoutines": [
{
"name": "\u0027Elixir.NervesHubWeb.Components.DevicePage.SettingsTab\u0027:hooked_event/3"
},
{
"name": "\u0027Elixir.NervesHubWeb.Components.DevicePage.SettingsTab\u0027:handle_progress/3"
},
{
"name": "\u0027Elixir.NervesHubWeb.Components.DevicePage.DetailsTab\u0027:hooked_event/3"
},
{
"name": "\u0027Elixir.NervesHubWeb.Live.DeploymentGroups.Show\u0027:handle_event/3"
},
{
"name": "\u0027Elixir.NervesHubWeb.Components.DeploymentGroupPage.Summary\u0027:handle_event/3"
},
{
"name": "\u0027Elixir.NervesHubWeb.Components.DeploymentGroupPage.Summary\u0027:handle_progress/3"
}
],
"repo": "https://github.com/nerves-hub/nerves_hub_web",
"vendor": "nerves-hub",
"versions": [
{
"lessThan": "34ea112472a460319c1e1dec8ad0ffc37df05ffe",
"status": "affected",
"version": "7b511764a468bb2c1f33945874faa86af2255c0c",
"versionType": "git"
}
]
}
],
"cpeApplicability": [
{
"nodes": [
{
"cpeMatch": [
{
"criteria": "cpe:2.3:a:nerves-hub:nerves_hub_web:*:*:*:*:*:*:*:*",
"versionEndExcluding": "2.5.3",
"versionStartIncluding": "1.0.0",
"vulnerable": true
}
],
"negate": false,
"operator": "OR"
}
],
"operator": "AND"
}
],
"credits": [
{
"lang": "en",
"type": "finder",
"value": "arbor-s"
},
{
"lang": "en",
"type": "reporter",
"value": "arbor-s"
},
{
"lang": "en",
"type": "remediation developer",
"value": "Josh Kalderimis"
},
{
"lang": "en",
"type": "coordinator",
"value": "Jonatan M\u00e4nnchen / EEF"
}
],
"dateAssigned": "2026-11-09T22:27:53.000Z",
"datePublic": "2026-11-10T10:44:08.000Z",
"descriptions": [
{
"lang": "en",
"supportingMedia": [
{
"base64": false,
"type": "text/html",
"value": "\u003cp\u003eMissing Authorization vulnerability in the device and deployment group pages in nerves-hub nerves_hub_web allows an authenticated organization member with the \u003ccode\u003eview\u003c/code\u003e role to change devices and deployment groups via LiveView events reserved for the \u003ccode\u003emanage\u003c/code\u003e role. The event handlers that add and delete device certificates, add and remove tags, overwrite or clear a device\u0027s manual location, remove a device from its deployment group, move matched devices into a deployment group or remove unmatched ones, import devices from a CSV, and start, retry or delete firmware delta builds carry out the change without calling \u003ccode\u003eauthorized!/2\u003c/code\u003e, and the pages render the controls to every member.\u003c/p\u003e\n\u003cp\u003eA \u003ccode\u003eview\u003c/code\u003e member can add a certificate that lets whoever holds its key connect as the device, delete a certificate so the device can no longer authenticate with it, and change which deployment group, and so which firmware release, a device receives. Everything stays within the organizations the member belongs to.\u003c/p\u003e\n\u003cp\u003eThis issue affects nerves_hub: from 1.0.0 before 2.5.3.\u003c/p\u003e"
},
{
"base64": false,
"type": "text/markdown",
"value": "Missing Authorization vulnerability in the device and deployment group pages in nerves-hub nerves_hub_web allows an authenticated organization member with the `view` role to change devices and deployment groups via LiveView events reserved for the `manage` role. The event handlers that add and delete device certificates, add and remove tags, overwrite or clear a device\u0027s manual location, remove a device from its deployment group, move matched devices into a deployment group or remove unmatched ones, import devices from a CSV, and start, retry or delete firmware delta builds carry out the change without calling `authorized!/2`, and the pages render the controls to every member.\n\nA `view` member can add a certificate that lets whoever holds its key connect as the device, delete a certificate so the device can no longer authenticate with it, and change which deployment group, and so which firmware release, a device receives. Everything stays within the organizations the member belongs to.\n\nThis issue affects nerves_hub: from 1.0.0 before 2.5.3."
}
],
"value": "Missing Authorization vulnerability in the device and deployment group pages in nerves-hub nerves_hub_web allows an authenticated organization member with the view role to change devices and deployment groups via LiveView events reserved for the manage role. The event handlers that add and delete device certificates, add and remove tags, overwrite or clear a device\u0027s manual location, remove a device from its deployment group, move matched devices into a deployment group or remove unmatched ones, import devices from a CSV, and start, retry or delete firmware delta builds carry out the change without calling authorized!/2, and the pages render the controls to every member.\n\nA view member can add a certificate that lets whoever holds its key connect as the device, delete a certificate so the device can no longer authenticate with it, and change which deployment group, and so which firmware release, a device receives. Everything stays within the organizations the member belongs to.\n\nThis issue affects nerves_hub: from 1.0.0 before 2.5.3."
}
],
"impacts": [
{
"capecId": "CAPEC-1",
"descriptions": [
{
"lang": "en",
"supportingMedia": [
{
"base64": false,
"type": "text/html",
"value": "\u003cp\u003eAn organization member with the view role can add or delete a device\u0027s certificates, change its tags and location, and move devices into or out of deployment groups, which changes the firmware release they receive. An added certificate lets whoever holds its key connect as the device and persists after the member leaves. Everything stays within the organizations the member belongs to.\u003c/p\u003e"
},
{
"base64": false,
"type": "text/markdown",
"value": "An organization member with the view role can add or delete a device\u0027s certificates, change its tags and location, and move devices into or out of deployment groups, which changes the firmware release they receive. An added certificate lets whoever holds its key connect as the device and persists after the member leaves. Everything stays within the organizations the member belongs to."
}
],
"value": "An organization member with the view role can add or delete a device\u0027s certificates, change its tags and location, and move devices into or out of deployment groups, which changes the firmware release they receive. An added certificate lets whoever holds its key connect as the device and persists after the member leaves. Everything stays within the organizations the member belongs to."
}
]
}
],
"metrics": [
{
"cvssV4_0": {
"Automatable": "NOT_DEFINED",
"Recovery": "NOT_DEFINED",
"Safety": "NOT_DEFINED",
"attackComplexity": "LOW",
"attackRequirements": "NONE",
"attackVector": "NETWORK",
"baseScore": 5.3,
"baseSeverity": "MEDIUM",
"privilegesRequired": "LOW",
"providerUrgency": "NOT_DEFINED",
"subAvailabilityImpact": "NONE",
"subConfidentialityImpact": "NONE",
"subIntegrityImpact": "LOW",
"userInteraction": "NONE",
"valueDensity": "NOT_DEFINED",
"vectorString": "CVSS:4.0/AV:N/AC:L/AT:N/PR:L/UI:N/VC:L/VI:L/VA:L/SC:N/SI:L/SA:N",
"version": "4.0",
"vulnAvailabilityImpact": "LOW",
"vulnConfidentialityImpact": "LOW",
"vulnIntegrityImpact": "LOW",
"vulnerabilityResponseEffort": "NOT_DEFINED"
},
"format": "CVSS",
"scenarios": [
{
"lang": "en",
"value": "GENERAL"
}
]
}
],
"problemTypes": [
{
"descriptions": [
{
"cweId": "CWE-862",
"description": "CWE-862 Missing Authorization",
"lang": "en",
"type": "CWE"
}
]
}
],
"providerMetadata": {
"dateUpdated": "2026-11-11T19:35:12.088Z",
"orgId": "6b3ad84c-e1a6-4bf7-a703-f496b71e49db",
"shortName": "EEF"
},
"references": [
{
"name": "Users with the \u2018view\u2019 role can change devices and deployment groups and read local shell output",
"tags": [
"vendor-advisory",
"related"
],
"url": "https://github.com/nerves-hub/nerves_hub_web/security/advisories/GHSA-gxj7-m8x6-r644"
},
{
"name": "EEF CNA record for CVE-2026-120286",
"tags": [
"related"
],
"url": "https://cna.erlef.org/cves/CVE-2026-120286.html"
},
{
"name": "OSV record EEF-CVE-2026-120286",
"tags": [
"related"
],
"url": "https://osv.dev/vulnerability/EEF-CVE-2026-120286"
},
{
"name": "Introducing commit 7b51176 in nerves-hub/nerves_hub_web",
"tags": [
"related"
],
"url": "https://github.com/nerves-hub/nerves_hub_web/commit/7b511764a468bb2c1f33945874faa86af2255c0c"
},
{
"name": "Fix commit 34ea112 in nerves-hub/nerves_hub_web",
"tags": [
"patch"
],
"url": "https://github.com/nerves-hub/nerves_hub_web/commit/34ea112472a460319c1e1dec8ad0ffc37df05ffe"
}
],
"solutions": [
{
"lang": "en",
"supportingMedia": [
{
"base64": false,
"type": "text/html",
"value": "\u003cp\u003eUpgrading does not remove a certificate that a \u003ccode\u003eview\u003c/code\u003e member already added. After upgrading, review each device\u0027s certificates and delete any you do not recognize.\u003c/p\u003e"
},
{
"base64": false,
"type": "text/markdown",
"value": "Upgrading does not remove a certificate that a `view` member already added. After upgrading, review each device\u0027s certificates and delete any you do not recognize."
}
],
"value": "Upgrading does not remove a certificate that a view member already added. After upgrading, review each device\u0027s certificates and delete any you do not recognize."
}
],
"source": {
"discovery": "UNKNOWN"
},
"title": "Missing authorization in device and deployment group page events allows view-role members to change devices and deployment groups",
"workarounds": [
{
"lang": "en",
"supportingMedia": [
{
"base64": false,
"type": "text/html",
"value": "\u003cp\u003eNo configuration closes the hole. Until the upgrade, give the \u003ccode\u003eview\u003c/code\u003e role only to people you would trust with the \u003ccode\u003emanage\u003c/code\u003e role on that organization\u0027s devices.\u003c/p\u003e"
},
{
"base64": false,
"type": "text/markdown",
"value": "No configuration closes the hole. Until the upgrade, give the `view` role only to people you would trust with the `manage` role on that organization\u0027s devices."
}
],
"value": "No configuration closes the hole. Until the upgrade, give the view role only to people you would trust with the manage role on that organization\u0027s devices."
}
],
"x_proofOfConcept": [
{
"lang": "en",
"supportingMedia": [
{
"base64": false,
"type": "text/html",
"value": "\u003col\u003e\n\u003cli\u003eCreate deployment group \u003ccode\u003ecanary\u003c/code\u003e with conditions matching tag \u003ccode\u003ebeta\u003c/code\u003e.\u003c/li\u003e\n\u003cli\u003eCreate \u003ccode\u003edevice_outside_match\u003c/code\u003e in the same product with tag \u003ccode\u003ebeta\u003c/code\u003e and no deployment group, and \u003ccode\u003edevice_inside_mismatch\u003c/code\u003e in deployment group \u003ccode\u003ecanary\u003c/code\u003e with tag \u003ccode\u003efoo\u003c/code\u003e.\u003c/li\u003e\n\u003cli\u003eSign in as a member of the organization with the \u003ccode\u003eview\u003c/code\u003e role and open \u003ccode\u003e/org/\u0026lt;org\u0026gt;/\u0026lt;product\u0026gt;/deployment_groups/canary\u003c/code\u003e.\u003c/li\u003e\n\u003cli\u003eClick \u003ccode\u003eMove device(s)\u003c/code\u003e: \u003ccode\u003edevice_outside_match\u003c/code\u003e is now in \u003ccode\u003ecanary\u003c/code\u003e. Click \u003ccode\u003eRemove device(s)\u003c/code\u003e: \u003ccode\u003edevice_inside_mismatch\u003c/code\u003e no longer has a deployment group.\u003c/li\u003e\n\u003cli\u003eAs the same member, open a device in the product and add a tag. The tag is saved.\u003c/li\u003e\n\u003cli\u003eOn the device\u0027s settings tab, delete a certificate, or upload a self-signed one. The change is saved, and a client presenting the uploaded certificate connects as the device.\u003c/li\u003e\n\u003c/ol\u003e"
},
{
"base64": false,
"type": "text/markdown",
"value": "1. Create deployment group `canary` with conditions matching tag `beta`.\n2. Create `device_outside_match` in the same product with tag `beta` and no deployment group, and `device_inside_mismatch` in deployment group `canary` with tag `foo`.\n3. Sign in as a member of the organization with the `view` role and open `/org/\u003corg\u003e/\u003cproduct\u003e/deployment_groups/canary`.\n4. Click `Move device(s)`: `device_outside_match` is now in `canary`. Click `Remove device(s)`: `device_inside_mismatch` no longer has a deployment group.\n5. As the same member, open a device in the product and add a tag. The tag is saved.\n6. On the device\u0027s settings tab, delete a certificate, or upload a self-signed one. The change is saved, and a client presenting the uploaded certificate connects as the device."
}
],
"value": "* Create deployment group canary with conditions matching tag beta.\n* Create device_outside_match in the same product with tag beta and no deployment group, and device_inside_mismatch in deployment group canary with tag foo.\n* Sign in as a member of the organization with the view role and open /org/\u003corg\u003e/\u003cproduct\u003e/deployment_groups/canary.\n* Click Move device(s): device_outside_match is now in canary. Click Remove device(s): device_inside_mismatch no longer has a deployment group.\n* As the same member, open a device in the product and add a tag. The tag is saved.\n* On the device\u0027s settings tab, delete a certificate, or upload a self-signed one. The change is saved, and a client presenting the uploaded certificate connects as the device."
}
],
"x_technicalAnalysis": [
{
"lang": "en",
"supportingMedia": [
{
"base64": false,
"type": "text/html",
"value": "\u003cp\u003eThe dashboard reads the member\u0027s role into \u003ccode\u003ecurrent_scope\u003c/code\u003e when a page mounts, and each privileged event handler is expected to call \u003ccode\u003eauthorized!/2\u003c/code\u003e with the permission it needs, all of which resolve to the \u003ccode\u003emanage\u003c/code\u003e role in \u003ccode\u003elib/nerves_hub_web/helpers/authorization.ex\u003c/code\u003e. These handlers never do:\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003eDevice settings tab\u003c/strong\u003e (\u003ccode\u003eNervesHubWeb.Components.DevicePage.SettingsTab\u003c/code\u003e): \u003ccode\u003ehooked_event/3\u003c/code\u003e for \u003ccode\u003edelete-certificate\u003c/code\u003e, and \u003ccode\u003ehandle_progress/3\u003c/code\u003e for the \u003ccode\u003ecertificate\u003c/code\u003e upload, which should require \u003ccode\u003edevice:update\u003c/code\u003e.\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003eDevice details tab\u003c/strong\u003e (\u003ccode\u003eNervesHubWeb.Components.DevicePage.DetailsTab\u003c/code\u003e): \u003ccode\u003ehooked_event/3\u003c/code\u003e for \u003ccode\u003eadd-tag\u003c/code\u003e, \u003ccode\u003eremove-tag\u003c/code\u003e, \u003ccode\u003esave-location-changes\u003c/code\u003e and \u003ccode\u003eclear-manual-location-information\u003c/code\u003e (\u003ccode\u003edevice:update\u003c/code\u003e), and \u003ccode\u003eremove-from-deployment-group\u003c/code\u003e (\u003ccode\u003edevice:set-deployment-group\u003c/code\u003e).\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003eDeployment group page\u003c/strong\u003e (\u003ccode\u003eNervesHubWeb.Live.DeploymentGroups.Show\u003c/code\u003e and \u003ccode\u003eNervesHubWeb.Components.DeploymentGroupPage.Summary\u003c/code\u003e): \u003ccode\u003ehandle_event/3\u003c/code\u003e for \u003ccode\u003emove-matched-devices-to-deployment-group\u003c/code\u003e and \u003ccode\u003eremove-unmatched-devices-from-deployment-group\u003c/code\u003e, and \u003ccode\u003ehandle_progress/3\u003c/code\u003e for the \u003ccode\u003edevice_csv\u003c/code\u003e upload (\u003ccode\u003edevice:set-deployment-group\u003c/code\u003e), plus \u003ccode\u003egenerate-firmware-deltas\u003c/code\u003e, \u003ccode\u003eretry_delta\u003c/code\u003e and \u003ccode\u003edelete_delta\u003c/code\u003e (\u003ccode\u003edeployment_group:update\u003c/code\u003e).\u003c/p\u003e\n\u003cp\u003eThe pages show every one of these controls to \u003ccode\u003eview\u003c/code\u003e members, so each change takes a click, and a client can send the events directly over the LiveView socket regardless.\u003c/p\u003e"
},
{
"base64": false,
"type": "text/markdown",
"value": "The dashboard reads the member\u0027s role into `current_scope` when a page mounts, and each privileged event handler is expected to call `authorized!/2` with the permission it needs, all of which resolve to the `manage` role in `lib/nerves_hub_web/helpers/authorization.ex`. These handlers never do:\n\n**Device settings tab** (`NervesHubWeb.Components.DevicePage.SettingsTab`): `hooked_event/3` for `delete-certificate`, and `handle_progress/3` for the `certificate` upload, which should require `device:update`.\n\n**Device details tab** (`NervesHubWeb.Components.DevicePage.DetailsTab`): `hooked_event/3` for `add-tag`, `remove-tag`, `save-location-changes` and `clear-manual-location-information` (`device:update`), and `remove-from-deployment-group` (`device:set-deployment-group`).\n\n**Deployment group page** (`NervesHubWeb.Live.DeploymentGroups.Show` and `NervesHubWeb.Components.DeploymentGroupPage.Summary`): `handle_event/3` for `move-matched-devices-to-deployment-group` and `remove-unmatched-devices-from-deployment-group`, and `handle_progress/3` for the `device_csv` upload (`device:set-deployment-group`), plus `generate-firmware-deltas`, `retry_delta` and `delete_delta` (`deployment_group:update`).\n\nThe pages show every one of these controls to `view` members, so each change takes a click, and a client can send the events directly over the LiveView socket regardless."
}
],
"value": "The dashboard reads the member\u0027s role into current_scope when a page mounts, and each privileged event handler is expected to call authorized!/2 with the permission it needs, all of which resolve to the manage role in lib/nerves_hub_web/helpers/authorization.ex. These handlers never do:\n\nDevice settings tab (NervesHubWeb.Components.DevicePage.SettingsTab): hooked_event/3 for delete-certificate, and handle_progress/3 for the certificate upload, which should require device:update.\n\nDevice details tab (NervesHubWeb.Components.DevicePage.DetailsTab): hooked_event/3 for add-tag, remove-tag, save-location-changes and clear-manual-location-information (device:update), and remove-from-deployment-group (device:set-deployment-group).\n\nDeployment group page (NervesHubWeb.Live.DeploymentGroups.Show and NervesHubWeb.Components.DeploymentGroupPage.Summary): handle_event/3 for move-matched-devices-to-deployment-group and remove-unmatched-devices-from-deployment-group, and handle_progress/3 for the device_csv upload (device:set-deployment-group), plus generate-firmware-deltas, retry_delta and delete_delta (deployment_group:update).\n\nThe pages show every one of these controls to view members, so each change takes a click, and a client can send the events directly over the LiveView socket regardless."
}
]
}
},
"cveMetadata": {
"assignerOrgId": "6b3ad84c-e1a6-4bf7-a703-f496b71e49db",
"assignerShortName": "EEF",
"cveId": "CVE-2026-120286",
"datePublished": "2026-11-10T10:44:07.907Z",
"dateReserved": "2026-11-04T17:15:01.545Z",
"dateUpdated": "2026-11-12T17:47:59.045Z",
"state": "PUBLISHED"
},
"dataType": "CVE_RECORD",
"dataVersion": "5.2"
}
CVE-2026-120997 (GCVE-0-2026-120997)
Vulnerability from cvelistv5 – Published: 2026-11-10 10:43 – Updated: 2026-11-12 17:47
VLAI
EPSS
VEX
Title
Improper session invalidation keeps removed or demoted members' open pages and device consoles working at their old role
Summary
Improper Session Invalidation vulnerability in the dashboard pages, device console and local shell channels in nerves-hub nerves_hub_web allows an organization member who is removed or given a lower role to keep acting at their old role through anything they had open at the time. The dashboard reads the member's role once when a page mounts, in the assign_org_to_scope hook of NervesHubWeb.Auth, and the console, local shell and device event stream channels check membership once at join. Removing a member or changing their role tells none of them, so open pages keep carrying out actions and open consoles keep running commands on the device until the page reloads or the socket disconnects.
The dashboard also opens consoles over NervesHubWeb.UserSocket with a token that holds only the user's id, is accepted for 24 hours, and is not revoked by signing out, which disconnects only the session's LiveViews. Whoever holds a token taken from a page can open consoles as that user, within the user's current membership, until it expires.
This issue affects nerves_hub: from 1.0.0 before 2.5.3.
Severity
SSVC
Exploitation: poc
Automatable: no
Technical Impact: partial
CISA Coordinator · CISA-ADP (v2.0.3)
Decision recorded 2026-11-12 17:46 UTC
CWE
Assigner
References
5 references
| URL | Tags |
|---|---|
| https://github.com/nerves-hub/nerves_hub_web/secu… | vendor-advisoryrelated |
| https://cna.erlef.org/cves/CVE-2026-120997.html | related |
| https://osv.dev/vulnerability/EEF-CVE-2026-120997 | related |
| https://github.com/nerves-hub/nerves_hub_web/comm… | related |
| https://github.com/nerves-hub/nerves_hub_web/comm… | patch |
Impacted products
3 products
| Vendor | Product | Version | |
|---|---|---|---|
| nerves-hub | nerves_hub_web |
Affected:
1.0.0 , < 2.5.3
(other)
cpe:2.3:a:nerves-hub:nerves_hub_web:*:*:*:*:*:*:*:* |
|
| nerves-hub | nerves_hub_web |
Affected:
1.0.0 , < 2.5.3
(semver)
cpe:2.3:a:nerves-hub:nerves_hub_web:*:*:*:*:*:*:*:* |
|
| nerves-hub | nerves_hub_web |
Affected:
b5d2438677f79d10e91aeef20a7b5cd8cca0d63c , < a96497ddd4d24ab1847f7685e255bf96fd87b85b
(git)
cpe:2.3:a:nerves-hub:nerves_hub_web:*:*:*:*:*:*:*:* |
Date Public
2026-11-10 10:43
{
"containers": {
"adp": [
{
"metrics": [
{
"other": {
"content": {
"id": "CVE-2026-120997",
"options": [
{
"Exploitation": "poc"
},
{
"Automatable": "no"
},
{
"Technical Impact": "partial"
}
],
"role": "CISA Coordinator",
"timestamp": "2026-11-12T17:46:56.169469Z",
"version": "2.0.3"
},
"type": "ssvc"
}
}
],
"providerMetadata": {
"dateUpdated": "2026-11-12T17:47:18.532Z",
"orgId": "134c704f-9b21-4f2e-91b3-4a467353bcc0",
"shortName": "CISA-ADP"
},
"references": [
{
"tags": [
"exploit"
],
"url": "https://github.com/nerves-hub/nerves_hub_web/security/advisories/GHSA-c7jf-qcx9-xxf8"
}
],
"title": "CISA ADP Vulnrichment"
}
],
"cna": {
"affected": [
{
"collectionURL": "https://ghcr.io",
"cpes": [
"cpe:2.3:a:nerves-hub:nerves_hub_web:*:*:*:*:*:*:*:*"
],
"defaultStatus": "unaffected",
"modules": [
"\u0027Elixir.NervesHubWeb.UserSocket\u0027",
"\u0027Elixir.NervesHubWeb.UserConsoleChannel\u0027",
"\u0027Elixir.NervesHubWeb.UserLocalShellChannel\u0027",
"\u0027Elixir.NervesHubWeb.DeviceEventsStreamChannel\u0027",
"\u0027Elixir.NervesHubWeb.Auth\u0027",
"\u0027Elixir.NervesHub.Accounts\u0027"
],
"packageName": "nerves-hub/nerves-hub",
"packageURL": "pkg:oci/nerves-hub?repository_url=ghcr.io%2Fnerves-hub%2Fnerves-hub",
"product": "nerves_hub_web",
"programFiles": [
"lib/nerves_hub_web/channels/user_socket.ex",
"lib/nerves_hub_web/channels/user_console_channel.ex",
"lib/nerves_hub_web/channels/user_local_shell_channel.ex",
"lib/nerves_hub_web/channels/device_events_stream_channel.ex",
"lib/nerves_hub_web/auth.ex",
"lib/nerves_hub/accounts.ex"
],
"programRoutines": [
{
"name": "\u0027Elixir.NervesHubWeb.UserSocket\u0027:connect/2"
},
{
"name": "\u0027Elixir.NervesHubWeb.UserSocket\u0027:id/1"
},
{
"name": "\u0027Elixir.NervesHubWeb.UserConsoleChannel\u0027:join/3"
},
{
"name": "\u0027Elixir.NervesHubWeb.UserLocalShellChannel\u0027:join/3"
},
{
"name": "\u0027Elixir.NervesHubWeb.DeviceEventsStreamChannel\u0027:join/3"
},
{
"name": "\u0027Elixir.NervesHubWeb.Auth\u0027:on_mount/4"
},
{
"name": "\u0027Elixir.NervesHubWeb.Auth\u0027:log_out_user/1"
},
{
"name": "\u0027Elixir.NervesHub.Accounts\u0027:soft_delete_org_user/1"
},
{
"name": "\u0027Elixir.NervesHub.Accounts\u0027:change_org_user_role/2"
}
],
"vendor": "nerves-hub",
"versions": [
{
"lessThan": "2.5.3",
"status": "affected",
"version": "1.0.0",
"versionType": "other"
}
]
},
{
"cpes": [
"cpe:2.3:a:nerves-hub:nerves_hub_web:*:*:*:*:*:*:*:*"
],
"defaultStatus": "unaffected",
"modules": [
"\u0027Elixir.NervesHubWeb.UserSocket\u0027",
"\u0027Elixir.NervesHubWeb.UserConsoleChannel\u0027",
"\u0027Elixir.NervesHubWeb.UserLocalShellChannel\u0027",
"\u0027Elixir.NervesHubWeb.DeviceEventsStreamChannel\u0027",
"\u0027Elixir.NervesHubWeb.Auth\u0027",
"\u0027Elixir.NervesHub.Accounts\u0027"
],
"packageName": "nerves_hub",
"packageURL": "pkg:otp/nerves_hub?repository_url=https:%2F%2Fgithub.com%2Fnerves-hub%2Fnerves_hub_web\u0026vcs_url=git%2Bhttps:%2F%2Fgithub.com%2Fnerves-hub%2Fnerves_hub_web.git",
"product": "nerves_hub_web",
"programFiles": [
"lib/nerves_hub_web/channels/user_socket.ex",
"lib/nerves_hub_web/channels/user_console_channel.ex",
"lib/nerves_hub_web/channels/user_local_shell_channel.ex",
"lib/nerves_hub_web/channels/device_events_stream_channel.ex",
"lib/nerves_hub_web/auth.ex",
"lib/nerves_hub/accounts.ex"
],
"programRoutines": [
{
"name": "\u0027Elixir.NervesHubWeb.UserSocket\u0027:connect/2"
},
{
"name": "\u0027Elixir.NervesHubWeb.UserSocket\u0027:id/1"
},
{
"name": "\u0027Elixir.NervesHubWeb.UserConsoleChannel\u0027:join/3"
},
{
"name": "\u0027Elixir.NervesHubWeb.UserLocalShellChannel\u0027:join/3"
},
{
"name": "\u0027Elixir.NervesHubWeb.DeviceEventsStreamChannel\u0027:join/3"
},
{
"name": "\u0027Elixir.NervesHubWeb.Auth\u0027:on_mount/4"
},
{
"name": "\u0027Elixir.NervesHubWeb.Auth\u0027:log_out_user/1"
},
{
"name": "\u0027Elixir.NervesHub.Accounts\u0027:soft_delete_org_user/1"
},
{
"name": "\u0027Elixir.NervesHub.Accounts\u0027:change_org_user_role/2"
}
],
"repo": "https://github.com/nerves-hub/nerves_hub_web",
"vendor": "nerves-hub",
"versions": [
{
"lessThan": "2.5.3",
"status": "affected",
"version": "1.0.0",
"versionType": "semver"
}
]
},
{
"collectionURL": "https://github.com",
"cpes": [
"cpe:2.3:a:nerves-hub:nerves_hub_web:*:*:*:*:*:*:*:*"
],
"defaultStatus": "unaffected",
"modules": [
"\u0027Elixir.NervesHubWeb.UserSocket\u0027",
"\u0027Elixir.NervesHubWeb.UserConsoleChannel\u0027",
"\u0027Elixir.NervesHubWeb.UserLocalShellChannel\u0027",
"\u0027Elixir.NervesHubWeb.DeviceEventsStreamChannel\u0027",
"\u0027Elixir.NervesHubWeb.Auth\u0027",
"\u0027Elixir.NervesHub.Accounts\u0027"
],
"packageName": "nerves-hub/nerves_hub_web",
"packageURL": "pkg:github/nerves-hub/nerves_hub_web",
"product": "nerves_hub_web",
"programFiles": [
"lib/nerves_hub_web/channels/user_socket.ex",
"lib/nerves_hub_web/channels/user_console_channel.ex",
"lib/nerves_hub_web/channels/user_local_shell_channel.ex",
"lib/nerves_hub_web/channels/device_events_stream_channel.ex",
"lib/nerves_hub_web/auth.ex",
"lib/nerves_hub/accounts.ex"
],
"programRoutines": [
{
"name": "\u0027Elixir.NervesHubWeb.UserSocket\u0027:connect/2"
},
{
"name": "\u0027Elixir.NervesHubWeb.UserSocket\u0027:id/1"
},
{
"name": "\u0027Elixir.NervesHubWeb.UserConsoleChannel\u0027:join/3"
},
{
"name": "\u0027Elixir.NervesHubWeb.UserLocalShellChannel\u0027:join/3"
},
{
"name": "\u0027Elixir.NervesHubWeb.DeviceEventsStreamChannel\u0027:join/3"
},
{
"name": "\u0027Elixir.NervesHubWeb.Auth\u0027:on_mount/4"
},
{
"name": "\u0027Elixir.NervesHubWeb.Auth\u0027:log_out_user/1"
},
{
"name": "\u0027Elixir.NervesHub.Accounts\u0027:soft_delete_org_user/1"
},
{
"name": "\u0027Elixir.NervesHub.Accounts\u0027:change_org_user_role/2"
}
],
"repo": "https://github.com/nerves-hub/nerves_hub_web",
"vendor": "nerves-hub",
"versions": [
{
"lessThan": "a96497ddd4d24ab1847f7685e255bf96fd87b85b",
"status": "affected",
"version": "b5d2438677f79d10e91aeef20a7b5cd8cca0d63c",
"versionType": "git"
}
]
}
],
"cpeApplicability": [
{
"nodes": [
{
"cpeMatch": [
{
"criteria": "cpe:2.3:a:nerves-hub:nerves_hub_web:*:*:*:*:*:*:*:*",
"versionEndExcluding": "2.5.3",
"versionStartIncluding": "1.0.0",
"vulnerable": true
}
],
"negate": false,
"operator": "OR"
}
],
"operator": "AND"
}
],
"credits": [
{
"lang": "en",
"type": "finder",
"value": "Josh Kalderimis"
},
{
"lang": "en",
"type": "reporter",
"value": "Josh Kalderimis"
},
{
"lang": "en",
"type": "remediation developer",
"value": "Josh Kalderimis"
},
{
"lang": "en",
"type": "coordinator",
"value": "Jonatan M\u00e4nnchen / EEF"
}
],
"dateAssigned": "2026-11-09T23:03:50.000Z",
"datePublic": "2026-11-10T10:43:58.000Z",
"descriptions": [
{
"lang": "en",
"supportingMedia": [
{
"base64": false,
"type": "text/html",
"value": "\u003cp\u003eImproper Session Invalidation vulnerability in the dashboard pages, device console and local shell channels in nerves-hub nerves_hub_web allows an organization member who is removed or given a lower role to keep acting at their old role through anything they had open at the time. The dashboard reads the member\u0027s role once when a page mounts, in the \u003ccode\u003eassign_org_to_scope\u003c/code\u003e hook of \u003ccode\u003eNervesHubWeb.Auth\u003c/code\u003e, and the console, local shell and device event stream channels check membership once at join. Removing a member or changing their role tells none of them, so open pages keep carrying out actions and open consoles keep running commands on the device until the page reloads or the socket disconnects.\u003c/p\u003e\n\u003cp\u003eThe dashboard also opens consoles over \u003ccode\u003eNervesHubWeb.UserSocket\u003c/code\u003e with a token that holds only the user\u0027s id, is accepted for 24 hours, and is not revoked by signing out, which disconnects only the session\u0027s LiveViews. Whoever holds a token taken from a page can open consoles as that user, within the user\u0027s current membership, until it expires.\u003c/p\u003e\n\u003cp\u003eThis issue affects nerves_hub: from 1.0.0 before 2.5.3.\u003c/p\u003e"
},
{
"base64": false,
"type": "text/markdown",
"value": "Improper Session Invalidation vulnerability in the dashboard pages, device console and local shell channels in nerves-hub nerves_hub_web allows an organization member who is removed or given a lower role to keep acting at their old role through anything they had open at the time. The dashboard reads the member\u0027s role once when a page mounts, in the `assign_org_to_scope` hook of `NervesHubWeb.Auth`, and the console, local shell and device event stream channels check membership once at join. Removing a member or changing their role tells none of them, so open pages keep carrying out actions and open consoles keep running commands on the device until the page reloads or the socket disconnects.\n\nThe dashboard also opens consoles over `NervesHubWeb.UserSocket` with a token that holds only the user\u0027s id, is accepted for 24 hours, and is not revoked by signing out, which disconnects only the session\u0027s LiveViews. Whoever holds a token taken from a page can open consoles as that user, within the user\u0027s current membership, until it expires.\n\nThis issue affects nerves_hub: from 1.0.0 before 2.5.3."
}
],
"value": "Improper Session Invalidation vulnerability in the dashboard pages, device console and local shell channels in nerves-hub nerves_hub_web allows an organization member who is removed or given a lower role to keep acting at their old role through anything they had open at the time. The dashboard reads the member\u0027s role once when a page mounts, in the assign_org_to_scope hook of NervesHubWeb.Auth, and the console, local shell and device event stream channels check membership once at join. Removing a member or changing their role tells none of them, so open pages keep carrying out actions and open consoles keep running commands on the device until the page reloads or the socket disconnects.\n\nThe dashboard also opens consoles over NervesHubWeb.UserSocket with a token that holds only the user\u0027s id, is accepted for 24 hours, and is not revoked by signing out, which disconnects only the session\u0027s LiveViews. Whoever holds a token taken from a page can open consoles as that user, within the user\u0027s current membership, until it expires.\n\nThis issue affects nerves_hub: from 1.0.0 before 2.5.3."
}
],
"impacts": [
{
"capecId": "CAPEC-60",
"descriptions": [
{
"lang": "en",
"supportingMedia": [
{
"base64": false,
"type": "text/html",
"value": "\u003cp\u003eA member who is removed from an organization or given a lower role keeps their old access through whatever they had open at the time, including device consoles and local shells that run commands on the device, until the page reloads or the socket disconnects. Anyone holding the dashboard\u0027s socket token taken from a page can open consoles as that user, within that user\u0027s current permissions, for up to 24 hours after they sign out.\u003c/p\u003e"
},
{
"base64": false,
"type": "text/markdown",
"value": "A member who is removed from an organization or given a lower role keeps their old access through whatever they had open at the time, including device consoles and local shells that run commands on the device, until the page reloads or the socket disconnects. Anyone holding the dashboard\u0027s socket token taken from a page can open consoles as that user, within that user\u0027s current permissions, for up to 24 hours after they sign out."
}
],
"value": "A member who is removed from an organization or given a lower role keeps their old access through whatever they had open at the time, including device consoles and local shells that run commands on the device, until the page reloads or the socket disconnects. Anyone holding the dashboard\u0027s socket token taken from a page can open consoles as that user, within that user\u0027s current permissions, for up to 24 hours after they sign out."
}
]
}
],
"metrics": [
{
"cvssV4_0": {
"Automatable": "NOT_DEFINED",
"Recovery": "NOT_DEFINED",
"Safety": "NOT_DEFINED",
"attackComplexity": "LOW",
"attackRequirements": "PRESENT",
"attackVector": "NETWORK",
"baseScore": 2.3,
"baseSeverity": "LOW",
"privilegesRequired": "LOW",
"providerUrgency": "NOT_DEFINED",
"subAvailabilityImpact": "NONE",
"subConfidentialityImpact": "NONE",
"subIntegrityImpact": "LOW",
"userInteraction": "NONE",
"valueDensity": "NOT_DEFINED",
"vectorString": "CVSS:4.0/AV:N/AC:L/AT:P/PR:L/UI:N/VC:L/VI:L/VA:N/SC:N/SI:L/SA:N",
"version": "4.0",
"vulnAvailabilityImpact": "NONE",
"vulnConfidentialityImpact": "LOW",
"vulnIntegrityImpact": "LOW",
"vulnerabilityResponseEffort": "NOT_DEFINED"
},
"format": "CVSS",
"scenarios": [
{
"lang": "en",
"value": "GENERAL"
}
]
}
],
"problemTypes": [
{
"descriptions": [
{
"cweId": "CWE-1464",
"description": "CWE-1464 Improper Session Invalidation",
"lang": "en",
"type": "CWE"
}
]
},
{
"descriptions": [
{
"cweId": "CWE-1465",
"description": "CWE-1465 Improper Session Invalidation after User Logout",
"lang": "en",
"type": "CWE"
}
]
}
],
"providerMetadata": {
"dateUpdated": "2026-11-11T19:33:46.201Z",
"orgId": "6b3ad84c-e1a6-4bf7-a703-f496b71e49db",
"shortName": "EEF"
},
"references": [
{
"name": "Removing or demoting an org member doesn\u2019t close pages and consoles they already have open",
"tags": [
"vendor-advisory",
"related"
],
"url": "https://github.com/nerves-hub/nerves_hub_web/security/advisories/GHSA-c7jf-qcx9-xxf8"
},
{
"name": "EEF CNA record for CVE-2026-120997",
"tags": [
"related"
],
"url": "https://cna.erlef.org/cves/CVE-2026-120997.html"
},
{
"name": "OSV record EEF-CVE-2026-120997",
"tags": [
"related"
],
"url": "https://osv.dev/vulnerability/EEF-CVE-2026-120997"
},
{
"name": "Introducing commit b5d2438 in nerves-hub/nerves_hub_web",
"tags": [
"related"
],
"url": "https://github.com/nerves-hub/nerves_hub_web/commit/b5d2438677f79d10e91aeef20a7b5cd8cca0d63c"
},
{
"name": "Fix commit a96497d in nerves-hub/nerves_hub_web",
"tags": [
"patch"
],
"url": "https://github.com/nerves-hub/nerves_hub_web/commit/a96497ddd4d24ab1847f7685e255bf96fd87b85b"
}
],
"source": {
"discovery": "UNKNOWN"
},
"title": "Improper session invalidation keeps removed or demoted members\u0027 open pages and device consoles working at their old role",
"workarounds": [
{
"lang": "en",
"supportingMedia": [
{
"base64": false,
"type": "text/html",
"value": "\u003cp\u003eAfter removing a member or lowering their role, restart the web nodes. That closes every open page and console, and each one is checked again when it reconnects. A socket token cannot be revoked before it expires short of changing \u003ccode\u003eSECRET_KEY_BASE\u003c/code\u003e, which signs everyone out.\u003c/p\u003e"
},
{
"base64": false,
"type": "text/markdown",
"value": "After removing a member or lowering their role, restart the web nodes. That closes every open page and console, and each one is checked again when it reconnects. A socket token cannot be revoked before it expires short of changing `SECRET_KEY_BASE`, which signs everyone out."
}
],
"value": "After removing a member or lowering their role, restart the web nodes. That closes every open page and console, and each one is checked again when it reconnects. A socket token cannot be revoked before it expires short of changing SECRET_KEY_BASE, which signs everyone out."
}
],
"x_proofOfConcept": [
{
"lang": "en",
"supportingMedia": [
{
"base64": false,
"type": "text/html",
"value": "\u003col\u003e\n\u003cli\u003eIn an organization, A is an admin and B has the manage role. B opens a device\u0027s page and its Console tab.\u003c/li\u003e\n\u003cli\u003eA removes B from the organization on the organization\u0027s Users page, or lowers B\u0027s role to view.\u003c/li\u003e\n\u003cli\u003eWithout reloading, B\u0027s open page still carries out actions and B\u0027s console still accepts input, until B reloads the page or the socket disconnects.\u003c/li\u003e\n\u003cli\u003eFor the socket token: while signed in, copy the \u003ccode\u003edata-user-token\u003c/code\u003e attribute of the \u003ccode\u003e#console\u003c/code\u003e element, sign out, and within 24 hours connect a Phoenix socket client to \u003ccode\u003e/socket/websocket\u003c/code\u003e with that token. Joining \u003ccode\u003euser:console:identifier-\u0026lt;device identifier\u0026gt;\u003c/code\u003e succeeds.\u003c/li\u003e\n\u003c/ol\u003e"
},
{
"base64": false,
"type": "text/markdown",
"value": "1. In an organization, A is an admin and B has the manage role. B opens a device\u0027s page and its Console tab.\n2. A removes B from the organization on the organization\u0027s Users page, or lowers B\u0027s role to view.\n3. Without reloading, B\u0027s open page still carries out actions and B\u0027s console still accepts input, until B reloads the page or the socket disconnects.\n4. For the socket token: while signed in, copy the `data-user-token` attribute of the `#console` element, sign out, and within 24 hours connect a Phoenix socket client to `/socket/websocket` with that token. Joining `user:console:identifier-\u003cdevice identifier\u003e` succeeds."
}
],
"value": "* In an organization, A is an admin and B has the manage role. B opens a device\u0027s page and its Console tab.\n* A removes B from the organization on the organization\u0027s Users page, or lowers B\u0027s role to view.\n* Without reloading, B\u0027s open page still carries out actions and B\u0027s console still accepts input, until B reloads the page or the socket disconnects.\n* For the socket token: while signed in, copy the data-user-token attribute of the #console element, sign out, and within 24 hours connect a Phoenix socket client to /socket/websocket with that token. Joining user:console:identifier-\u003cdevice identifier\u003e succeeds."
}
]
}
},
"cveMetadata": {
"assignerOrgId": "6b3ad84c-e1a6-4bf7-a703-f496b71e49db",
"assignerShortName": "EEF",
"cveId": "CVE-2026-120997",
"datePublished": "2026-11-10T10:43:57.738Z",
"dateReserved": "2026-11-05T14:30:01.700Z",
"dateUpdated": "2026-11-12T17:47:18.532Z",
"state": "PUBLISHED"
},
"dataType": "CVE_RECORD",
"dataVersion": "5.2"
}
CVE-2026-120730 (GCVE-0-2026-120730)
Vulnerability from cvelistv5 – Published: 2026-11-10 10:43 – Updated: 2026-11-12 17:46
VLAI
EPSS
VEX
Title
Mass assignment of organization, product and deployment group IDs allows users to write into other organizations and receive their firmware
Summary
Improperly Controlled Modification of Dynamically-Determined Object Attributes vulnerability in the device API, device forms, signing key and product forms and JITP settings in nerves-hub nerves_hub_web allows an authenticated user who manages devices in any organization to write into other organizations via submitted org_id, product_id and deployment_id fields. The device API create and update actions and the dashboard device forms pass the request parameters to NervesHub.Devices.Device.changeset/2, which casts the organization, product, deployment group and update state as given. The signing key and product forms merge the session's organization into the submitted parameters so that a submitted org_id wins, and the JITP changeset accepts any product_id. Each request requires the manage role only in the user's own organization, and any account can create one.
A device placed on another organization's deployment group, or moved into its product, is sent that organization's firmware with a download URL. The user cannot change or control devices that already belong to the other organization, and cannot upload firmware to it.
This issue affects nerves_hub: from 1.0.0 before 2.5.3.
Severity
SSVC
Exploitation: poc
Automatable: no
Technical Impact: total
CISA Coordinator · CISA-ADP (v2.0.3)
Decision recorded 2026-11-12 17:46 UTC
CWE
Assigner
References
5 references
| URL | Tags |
|---|---|
| https://github.com/nerves-hub/nerves_hub_web/secu… | vendor-advisoryrelated |
| https://cna.erlef.org/cves/CVE-2026-120730.html | related |
| https://osv.dev/vulnerability/EEF-CVE-2026-120730 | related |
| https://github.com/nerves-hub/nerves_hub_web/comm… | related |
| https://github.com/nerves-hub/nerves_hub_web/comm… | patch |
Impacted products
3 products
| Vendor | Product | Version | |
|---|---|---|---|
| nerves-hub | nerves_hub_web |
Affected:
1.0.0 , < 2.5.3
(other)
cpe:2.3:a:nerves-hub:nerves_hub_web:*:*:*:*:*:*:*:* |
|
| nerves-hub | nerves_hub_web |
Affected:
1.0.0 , < 2.5.3
(semver)
cpe:2.3:a:nerves-hub:nerves_hub_web:*:*:*:*:*:*:*:* |
|
| nerves-hub | nerves_hub_web |
Affected:
f58d58233dbd355c58d3a140107ece18e8863f14 , < c367d7d17d07f843e78a70d15904665616dde964
(git)
cpe:2.3:a:nerves-hub:nerves_hub_web:*:*:*:*:*:*:*:* |
Date Public
2026-11-10 10:43
{
"containers": {
"adp": [
{
"metrics": [
{
"other": {
"content": {
"id": "CVE-2026-120730",
"options": [
{
"Exploitation": "poc"
},
{
"Automatable": "no"
},
{
"Technical Impact": "total"
}
],
"role": "CISA Coordinator",
"timestamp": "2026-11-12T17:46:09.344720Z",
"version": "2.0.3"
},
"type": "ssvc"
}
}
],
"providerMetadata": {
"dateUpdated": "2026-11-12T17:46:28.444Z",
"orgId": "134c704f-9b21-4f2e-91b3-4a467353bcc0",
"shortName": "CISA-ADP"
},
"references": [
{
"tags": [
"exploit"
],
"url": "https://github.com/nerves-hub/nerves_hub_web/security/advisories/GHSA-rpp7-x9hm-jrmr"
}
],
"title": "CISA ADP Vulnrichment"
}
],
"cna": {
"affected": [
{
"collectionURL": "https://ghcr.io",
"cpes": [
"cpe:2.3:a:nerves-hub:nerves_hub_web:*:*:*:*:*:*:*:*"
],
"defaultStatus": "unaffected",
"modules": [
"\u0027Elixir.NervesHubWeb.API.DeviceController\u0027",
"\u0027Elixir.NervesHub.Devices.Device\u0027",
"\u0027Elixir.NervesHubWeb.Components.DevicePage.SettingsTab\u0027",
"\u0027Elixir.NervesHubWeb.Live.Devices.New\u0027",
"\u0027Elixir.NervesHubWeb.Live.Org.SigningKeys\u0027",
"\u0027Elixir.NervesHubWeb.Live.Products.New\u0027",
"\u0027Elixir.NervesHub.Devices.CACertificate.JITP\u0027",
"\u0027Elixir.NervesHub.ManagedDeployments\u0027"
],
"packageName": "nerves-hub/nerves-hub",
"packageURL": "pkg:oci/nerves-hub?repository_url=ghcr.io%2Fnerves-hub%2Fnerves-hub",
"product": "nerves_hub_web",
"programFiles": [
"lib/nerves_hub_web/controllers/api/device_controller.ex",
"lib/nerves_hub/devices/device.ex",
"lib/nerves_hub_web/components/device_page/settings_tab.ex",
"lib/nerves_hub_web/live/devices/new.ex",
"lib/nerves_hub_web/live/org/signing_keys.ex",
"lib/nerves_hub_web/live/products/new.ex",
"lib/nerves_hub/devices/ca_certificate/jitp.ex",
"lib/nerves_hub/managed_deployments.ex"
],
"programRoutines": [
{
"name": "\u0027Elixir.NervesHubWeb.API.DeviceController\u0027:create/2"
},
{
"name": "\u0027Elixir.NervesHubWeb.API.DeviceController\u0027:update/2"
},
{
"name": "\u0027Elixir.NervesHub.Devices.Device\u0027:changeset/2"
},
{
"name": "\u0027Elixir.NervesHubWeb.Components.DevicePage.SettingsTab\u0027:hooked_event/3"
},
{
"name": "\u0027Elixir.NervesHubWeb.Live.Devices.New\u0027:handle_event/3"
},
{
"name": "\u0027Elixir.NervesHubWeb.Live.Org.SigningKeys\u0027:handle_event/3"
},
{
"name": "\u0027Elixir.NervesHubWeb.Live.Products.New\u0027:handle_event/3"
},
{
"name": "\u0027Elixir.NervesHub.Devices.CACertificate.JITP\u0027:changeset/2"
}
],
"vendor": "nerves-hub",
"versions": [
{
"lessThan": "2.5.3",
"status": "affected",
"version": "1.0.0",
"versionType": "other"
}
]
},
{
"cpes": [
"cpe:2.3:a:nerves-hub:nerves_hub_web:*:*:*:*:*:*:*:*"
],
"defaultStatus": "unaffected",
"modules": [
"\u0027Elixir.NervesHubWeb.API.DeviceController\u0027",
"\u0027Elixir.NervesHub.Devices.Device\u0027",
"\u0027Elixir.NervesHubWeb.Components.DevicePage.SettingsTab\u0027",
"\u0027Elixir.NervesHubWeb.Live.Devices.New\u0027",
"\u0027Elixir.NervesHubWeb.Live.Org.SigningKeys\u0027",
"\u0027Elixir.NervesHubWeb.Live.Products.New\u0027",
"\u0027Elixir.NervesHub.Devices.CACertificate.JITP\u0027",
"\u0027Elixir.NervesHub.ManagedDeployments\u0027"
],
"packageName": "nerves_hub",
"packageURL": "pkg:otp/nerves_hub?repository_url=https:%2F%2Fgithub.com%2Fnerves-hub%2Fnerves_hub_web\u0026vcs_url=git%2Bhttps:%2F%2Fgithub.com%2Fnerves-hub%2Fnerves_hub_web.git",
"product": "nerves_hub_web",
"programFiles": [
"lib/nerves_hub_web/controllers/api/device_controller.ex",
"lib/nerves_hub/devices/device.ex",
"lib/nerves_hub_web/components/device_page/settings_tab.ex",
"lib/nerves_hub_web/live/devices/new.ex",
"lib/nerves_hub_web/live/org/signing_keys.ex",
"lib/nerves_hub_web/live/products/new.ex",
"lib/nerves_hub/devices/ca_certificate/jitp.ex",
"lib/nerves_hub/managed_deployments.ex"
],
"programRoutines": [
{
"name": "\u0027Elixir.NervesHubWeb.API.DeviceController\u0027:create/2"
},
{
"name": "\u0027Elixir.NervesHubWeb.API.DeviceController\u0027:update/2"
},
{
"name": "\u0027Elixir.NervesHub.Devices.Device\u0027:changeset/2"
},
{
"name": "\u0027Elixir.NervesHubWeb.Components.DevicePage.SettingsTab\u0027:hooked_event/3"
},
{
"name": "\u0027Elixir.NervesHubWeb.Live.Devices.New\u0027:handle_event/3"
},
{
"name": "\u0027Elixir.NervesHubWeb.Live.Org.SigningKeys\u0027:handle_event/3"
},
{
"name": "\u0027Elixir.NervesHubWeb.Live.Products.New\u0027:handle_event/3"
},
{
"name": "\u0027Elixir.NervesHub.Devices.CACertificate.JITP\u0027:changeset/2"
}
],
"repo": "https://github.com/nerves-hub/nerves_hub_web",
"vendor": "nerves-hub",
"versions": [
{
"lessThan": "2.5.3",
"status": "affected",
"version": "1.0.0",
"versionType": "semver"
}
]
},
{
"collectionURL": "https://github.com",
"cpes": [
"cpe:2.3:a:nerves-hub:nerves_hub_web:*:*:*:*:*:*:*:*"
],
"defaultStatus": "unaffected",
"modules": [
"\u0027Elixir.NervesHubWeb.API.DeviceController\u0027",
"\u0027Elixir.NervesHub.Devices.Device\u0027",
"\u0027Elixir.NervesHubWeb.Components.DevicePage.SettingsTab\u0027",
"\u0027Elixir.NervesHubWeb.Live.Devices.New\u0027",
"\u0027Elixir.NervesHubWeb.Live.Org.SigningKeys\u0027",
"\u0027Elixir.NervesHubWeb.Live.Products.New\u0027",
"\u0027Elixir.NervesHub.Devices.CACertificate.JITP\u0027",
"\u0027Elixir.NervesHub.ManagedDeployments\u0027"
],
"packageName": "nerves-hub/nerves_hub_web",
"packageURL": "pkg:github/nerves-hub/nerves_hub_web",
"product": "nerves_hub_web",
"programFiles": [
"lib/nerves_hub_web/controllers/api/device_controller.ex",
"lib/nerves_hub/devices/device.ex",
"lib/nerves_hub_web/components/device_page/settings_tab.ex",
"lib/nerves_hub_web/live/devices/new.ex",
"lib/nerves_hub_web/live/org/signing_keys.ex",
"lib/nerves_hub_web/live/products/new.ex",
"lib/nerves_hub/devices/ca_certificate/jitp.ex",
"lib/nerves_hub/managed_deployments.ex"
],
"programRoutines": [
{
"name": "\u0027Elixir.NervesHubWeb.API.DeviceController\u0027:create/2"
},
{
"name": "\u0027Elixir.NervesHubWeb.API.DeviceController\u0027:update/2"
},
{
"name": "\u0027Elixir.NervesHub.Devices.Device\u0027:changeset/2"
},
{
"name": "\u0027Elixir.NervesHubWeb.Components.DevicePage.SettingsTab\u0027:hooked_event/3"
},
{
"name": "\u0027Elixir.NervesHubWeb.Live.Devices.New\u0027:handle_event/3"
},
{
"name": "\u0027Elixir.NervesHubWeb.Live.Org.SigningKeys\u0027:handle_event/3"
},
{
"name": "\u0027Elixir.NervesHubWeb.Live.Products.New\u0027:handle_event/3"
},
{
"name": "\u0027Elixir.NervesHub.Devices.CACertificate.JITP\u0027:changeset/2"
}
],
"repo": "https://github.com/nerves-hub/nerves_hub_web",
"vendor": "nerves-hub",
"versions": [
{
"lessThan": "c367d7d17d07f843e78a70d15904665616dde964",
"status": "affected",
"version": "f58d58233dbd355c58d3a140107ece18e8863f14",
"versionType": "git"
}
]
}
],
"cpeApplicability": [
{
"nodes": [
{
"cpeMatch": [
{
"criteria": "cpe:2.3:a:nerves-hub:nerves_hub_web:*:*:*:*:*:*:*:*",
"versionEndExcluding": "2.5.3",
"versionStartIncluding": "1.0.0",
"vulnerable": true
}
],
"negate": false,
"operator": "OR"
}
],
"operator": "AND"
}
],
"credits": [
{
"lang": "en",
"type": "finder",
"value": "Josh Kalderimis"
},
{
"lang": "en",
"type": "reporter",
"value": "Josh Kalderimis"
},
{
"lang": "en",
"type": "remediation developer",
"value": "Josh Kalderimis"
},
{
"lang": "en",
"type": "coordinator",
"value": "Jonatan M\u00e4nnchen / EEF"
}
],
"dateAssigned": "2026-11-09T22:45:21.000Z",
"datePublic": "2026-11-10T10:43:48.000Z",
"descriptions": [
{
"lang": "en",
"supportingMedia": [
{
"base64": false,
"type": "text/html",
"value": "\u003cp\u003eImproperly Controlled Modification of Dynamically-Determined Object Attributes vulnerability in the device API, device forms, signing key and product forms and JITP settings in nerves-hub nerves_hub_web allows an authenticated user who manages devices in any organization to write into other organizations via submitted \u003ccode\u003eorg_id\u003c/code\u003e, \u003ccode\u003eproduct_id\u003c/code\u003e and \u003ccode\u003edeployment_id\u003c/code\u003e fields. The device API create and update actions and the dashboard device forms pass the request parameters to \u003ccode\u003eNervesHub.Devices.Device.changeset/2\u003c/code\u003e, which casts the organization, product, deployment group and update state as given. The signing key and product forms merge the session\u0027s organization into the submitted parameters so that a submitted \u003ccode\u003eorg_id\u003c/code\u003e wins, and the JITP changeset accepts any \u003ccode\u003eproduct_id\u003c/code\u003e. Each request requires the \u003ccode\u003emanage\u003c/code\u003e role only in the user\u0027s own organization, and any account can create one.\u003c/p\u003e\n\u003cp\u003eA device placed on another organization\u0027s deployment group, or moved into its product, is sent that organization\u0027s firmware with a download URL. The user cannot change or control devices that already belong to the other organization, and cannot upload firmware to it.\u003c/p\u003e\n\u003cp\u003eThis issue affects nerves_hub: from 1.0.0 before 2.5.3.\u003c/p\u003e"
},
{
"base64": false,
"type": "text/markdown",
"value": "Improperly Controlled Modification of Dynamically-Determined Object Attributes vulnerability in the device API, device forms, signing key and product forms and JITP settings in nerves-hub nerves_hub_web allows an authenticated user who manages devices in any organization to write into other organizations via submitted `org_id`, `product_id` and `deployment_id` fields. The device API create and update actions and the dashboard device forms pass the request parameters to `NervesHub.Devices.Device.changeset/2`, which casts the organization, product, deployment group and update state as given. The signing key and product forms merge the session\u0027s organization into the submitted parameters so that a submitted `org_id` wins, and the JITP changeset accepts any `product_id`. Each request requires the `manage` role only in the user\u0027s own organization, and any account can create one.\n\nA device placed on another organization\u0027s deployment group, or moved into its product, is sent that organization\u0027s firmware with a download URL. The user cannot change or control devices that already belong to the other organization, and cannot upload firmware to it.\n\nThis issue affects nerves_hub: from 1.0.0 before 2.5.3."
}
],
"value": "Improperly Controlled Modification of Dynamically-Determined Object Attributes vulnerability in the device API, device forms, signing key and product forms and JITP settings in nerves-hub nerves_hub_web allows an authenticated user who manages devices in any organization to write into other organizations via submitted org_id, product_id and deployment_id fields. The device API create and update actions and the dashboard device forms pass the request parameters to NervesHub.Devices.Device.changeset/2, which casts the organization, product, deployment group and update state as given. The signing key and product forms merge the session\u0027s organization into the submitted parameters so that a submitted org_id wins, and the JITP changeset accepts any product_id. Each request requires the manage role only in the user\u0027s own organization, and any account can create one.\n\nA device placed on another organization\u0027s deployment group, or moved into its product, is sent that organization\u0027s firmware with a download URL. The user cannot change or control devices that already belong to the other organization, and cannot upload firmware to it.\n\nThis issue affects nerves_hub: from 1.0.0 before 2.5.3."
}
],
"impacts": [
{
"capecId": "CAPEC-77",
"descriptions": [
{
"lang": "en",
"supportingMedia": [
{
"base64": false,
"type": "text/html",
"value": "\u003cp\u003eAny user who manages devices in an organization of their own can attach their device to another organization\u0027s deployment group and receive its firmware, move devices into that organization\u0027s product, add firmware signing keys and products to it, and have a certificate authority provision devices into its product. Devices that already belong to the other organization are not controlled, and no firmware can be uploaded to it.\u003c/p\u003e"
},
{
"base64": false,
"type": "text/markdown",
"value": "Any user who manages devices in an organization of their own can attach their device to another organization\u0027s deployment group and receive its firmware, move devices into that organization\u0027s product, add firmware signing keys and products to it, and have a certificate authority provision devices into its product. Devices that already belong to the other organization are not controlled, and no firmware can be uploaded to it."
}
],
"value": "Any user who manages devices in an organization of their own can attach their device to another organization\u0027s deployment group and receive its firmware, move devices into that organization\u0027s product, add firmware signing keys and products to it, and have a certificate authority provision devices into its product. Devices that already belong to the other organization are not controlled, and no firmware can be uploaded to it."
}
]
}
],
"metrics": [
{
"cvssV4_0": {
"Automatable": "NOT_DEFINED",
"Recovery": "NOT_DEFINED",
"Safety": "NOT_DEFINED",
"attackComplexity": "LOW",
"attackRequirements": "NONE",
"attackVector": "NETWORK",
"baseScore": 8.6,
"baseSeverity": "HIGH",
"privilegesRequired": "LOW",
"providerUrgency": "NOT_DEFINED",
"subAvailabilityImpact": "NONE",
"subConfidentialityImpact": "NONE",
"subIntegrityImpact": "NONE",
"userInteraction": "NONE",
"valueDensity": "NOT_DEFINED",
"vectorString": "CVSS:4.0/AV:N/AC:L/AT:N/PR:L/UI:N/VC:H/VI:H/VA:N/SC:N/SI:N/SA:N",
"version": "4.0",
"vulnAvailabilityImpact": "NONE",
"vulnConfidentialityImpact": "HIGH",
"vulnIntegrityImpact": "HIGH",
"vulnerabilityResponseEffort": "NOT_DEFINED"
},
"format": "CVSS",
"scenarios": [
{
"lang": "en",
"value": "GENERAL"
}
]
}
],
"problemTypes": [
{
"descriptions": [
{
"cweId": "CWE-639",
"description": "CWE-639 Authorization Bypass Through User-Controlled Key",
"lang": "en",
"type": "CWE"
}
]
},
{
"descriptions": [
{
"cweId": "CWE-915",
"description": "CWE-915 Improperly Controlled Modification of Dynamically-Determined Object Attributes",
"lang": "en",
"type": "CWE"
}
]
}
],
"providerMetadata": {
"dateUpdated": "2026-11-11T20:09:55.746Z",
"orgId": "6b3ad84c-e1a6-4bf7-a703-f496b71e49db",
"shortName": "EEF"
},
"references": [
{
"name": "Users can receive another organization\u2019s firmware and add devices, signing keys and products to it",
"tags": [
"vendor-advisory",
"related"
],
"url": "https://github.com/nerves-hub/nerves_hub_web/security/advisories/GHSA-rpp7-x9hm-jrmr"
},
{
"name": "EEF CNA record for CVE-2026-120730",
"tags": [
"related"
],
"url": "https://cna.erlef.org/cves/CVE-2026-120730.html"
},
{
"name": "OSV record EEF-CVE-2026-120730",
"tags": [
"related"
],
"url": "https://osv.dev/vulnerability/EEF-CVE-2026-120730"
},
{
"name": "Introducing commit f58d582 in nerves-hub/nerves_hub_web",
"tags": [
"related"
],
"url": "https://github.com/nerves-hub/nerves_hub_web/commit/f58d58233dbd355c58d3a140107ece18e8863f14"
},
{
"name": "Fix commit c367d7d in nerves-hub/nerves_hub_web",
"tags": [
"patch"
],
"url": "https://github.com/nerves-hub/nerves_hub_web/commit/c367d7d17d07f843e78a70d15904665616dde964"
}
],
"solutions": [
{
"lang": "en",
"supportingMedia": [
{
"base64": false,
"type": "text/html",
"value": "\u003cp\u003eThe fixed release\u0027s migration takes every device off a deployment group that belongs to a different product and logs each one. Upgrading undoes nothing else done this way. After upgrading, check the database for devices whose organization does not own their product, for signing keys in \u003ccode\u003eorg_keys\u003c/code\u003e whose \u003ccode\u003ecreated_by_id\u003c/code\u003e has never been a member of the key\u0027s organization, and for certificate authorities whose JITP product belongs to another organization, and remove what you do not recognize. A device moved wholly into another organization, and a product added to one, leave nothing that sets them apart, so review your organization\u0027s devices and products as well.\u003c/p\u003e"
},
{
"base64": false,
"type": "text/markdown",
"value": "The fixed release\u0027s migration takes every device off a deployment group that belongs to a different product and logs each one. Upgrading undoes nothing else done this way. After upgrading, check the database for devices whose organization does not own their product, for signing keys in `org_keys` whose `created_by_id` has never been a member of the key\u0027s organization, and for certificate authorities whose JITP product belongs to another organization, and remove what you do not recognize. A device moved wholly into another organization, and a product added to one, leave nothing that sets them apart, so review your organization\u0027s devices and products as well."
}
],
"value": "The fixed release\u0027s migration takes every device off a deployment group that belongs to a different product and logs each one. Upgrading undoes nothing else done this way. After upgrading, check the database for devices whose organization does not own their product, for signing keys in org_keys whose created_by_id has never been a member of the key\u0027s organization, and for certificate authorities whose JITP product belongs to another organization, and remove what you do not recognize. A device moved wholly into another organization, and a product added to one, leave nothing that sets them apart, so review your organization\u0027s devices and products as well."
}
],
"source": {
"discovery": "UNKNOWN"
},
"title": "Mass assignment of organization, product and deployment group IDs allows users to write into other organizations and receive their firmware",
"x_proofOfConcept": [
{
"lang": "en",
"supportingMedia": [
{
"base64": false,
"type": "text/html",
"value": "\u003cp\u003eAs an admin of organization A, with organization B having an active deployment group whose current release is firmware B:\u003c/p\u003e\n\u003col\u003e\n\u003cli\u003e\u003ccode\u003ePOST /api/orgs/\u0026lt;A\u0026gt;/products/\u0026lt;A\u0027s product\u0026gt;/devices\u003c/code\u003e with \u003ccode\u003e{\"identifier\": \"planted\", \"deployment_id\": \u0026lt;B\u0027s deployment group ID\u0026gt;}\u003c/code\u003e. The device is created in A, on B\u0027s deployment group.\u003c/li\u003e\n\u003cli\u003eCreate a device key for it, then connect it, reporting the platform and architecture of firmware B. When the deployment runs, the device is sent an update for firmware B with its download URL.\u003c/li\u003e\n\u003cli\u003eTo move a device instead, \u003ccode\u003ePUT /api/orgs/\u0026lt;A\u0026gt;/products/\u0026lt;A\u0027s product\u0026gt;/devices/\u0026lt;identifier\u0026gt;\u003c/code\u003e with \u003ccode\u003e{\"org_id\": \u0026lt;B\u0027s ID\u0026gt;, \"product_id\": \u0026lt;B\u0027s product ID\u0026gt;}\u003c/code\u003e. The response is \u003ccode\u003e201\u003c/code\u003e, and the device is now in B\u0027s product.\u003c/li\u003e\n\u003c/ol\u003e"
},
{
"base64": false,
"type": "text/markdown",
"value": "As an admin of organization A, with organization B having an active deployment group whose current release is firmware B:\n\n1. `POST /api/orgs/\u003cA\u003e/products/\u003cA\u0027s product\u003e/devices` with `{\"identifier\": \"planted\", \"deployment_id\": \u003cB\u0027s deployment group ID\u003e}`. The device is created in A, on B\u0027s deployment group.\n2. Create a device key for it, then connect it, reporting the platform and architecture of firmware B. When the deployment runs, the device is sent an update for firmware B with its download URL.\n3. To move a device instead, `PUT /api/orgs/\u003cA\u003e/products/\u003cA\u0027s product\u003e/devices/\u003cidentifier\u003e` with `{\"org_id\": \u003cB\u0027s ID\u003e, \"product_id\": \u003cB\u0027s product ID\u003e}`. The response is `201`, and the device is now in B\u0027s product."
}
],
"value": "As an admin of organization A, with organization B having an active deployment group whose current release is firmware B:\n\n* POST /api/orgs/\u003cA\u003e/products/\u003cA\u0027s product\u003e/devices with {\"identifier\": \"planted\", \"deployment_id\": \u003cB\u0027s deployment group ID\u003e}. The device is created in A, on B\u0027s deployment group.\n* Create a device key for it, then connect it, reporting the platform and architecture of firmware B. When the deployment runs, the device is sent an update for firmware B with its download URL.\n* To move a device instead, PUT /api/orgs/\u003cA\u003e/products/\u003cA\u0027s product\u003e/devices/\u003cidentifier\u003e with {\"org_id\": \u003cB\u0027s ID\u003e, \"product_id\": \u003cB\u0027s product ID\u003e}. The response is 201, and the device is now in B\u0027s product."
}
],
"x_technicalAnalysis": [
{
"lang": "en",
"supportingMedia": [
{
"base64": false,
"type": "text/html",
"value": "\u003cp\u003e\u003cstrong\u003e1. Device API and device forms.\u003c/strong\u003e \u003ccode\u003eNervesHubWeb.API.DeviceController.create/2\u003c/code\u003e sets \u003ccode\u003eorg_id\u003c/code\u003e and \u003ccode\u003eproduct_id\u003c/code\u003e from the route, then hands the rest of the request to \u003ccode\u003eDevice.changeset/2\u003c/code\u003e; \u003ccode\u003eupdate/2\u003c/code\u003e hands the whole request over. The changeset casts \u003ccode\u003eorg_id\u003c/code\u003e, \u003ccode\u003eproduct_id\u003c/code\u003e, \u003ccode\u003edeployment_id\u003c/code\u003e, \u003ccode\u003edeleted_at\u003c/code\u003e and the server-managed update state, so an update can move the device into another organization\u0027s product and either request can place it on another organization\u0027s deployment group. The device settings form in \u003ccode\u003eNervesHubWeb.Components.DevicePage.SettingsTab\u003c/code\u003e and the new device form in \u003ccode\u003eNervesHubWeb.Live.Devices.New\u003c/code\u003e reach the same changeset with the same effect.\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003e2. Signing key and product forms.\u003c/strong\u003e \u003ccode\u003eNervesHubWeb.Live.Org.SigningKeys\u003c/code\u003e and \u003ccode\u003eNervesHubWeb.Live.Products.New\u003c/code\u003e build their parameters with \u003ccode\u003eEnum.into(submitted, %{\"org_id\" =\u0026gt; session_org_id})\u003c/code\u003e, so a submitted \u003ccode\u003eorg_id\u003c/code\u003e overrides the session\u0027s, and the key or product is created in the organization named by the client.\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003e3. JITP settings.\u003c/strong\u003e \u003ccode\u003eNervesHub.Devices.CACertificate.JITP.changeset/2\u003c/code\u003e casts any \u003ccode\u003eproduct_id\u003c/code\u003e, through the API and the dashboard, so devices the certificate authority provisions are created in another organization\u0027s product.\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003e4. Firmware delivery.\u003c/strong\u003e When a planted device connects, \u003ccode\u003eNervesHub.ManagedDeployments\u003c/code\u003e checks its deployment group membership by firmware version, platform and architecture only, all reported by the device, so it stays on the foreign group and is sent that group\u0027s current release with a download URL.\u003c/p\u003e"
},
{
"base64": false,
"type": "text/markdown",
"value": "**1. Device API and device forms.** `NervesHubWeb.API.DeviceController.create/2` sets `org_id` and `product_id` from the route, then hands the rest of the request to `Device.changeset/2`; `update/2` hands the whole request over. The changeset casts `org_id`, `product_id`, `deployment_id`, `deleted_at` and the server-managed update state, so an update can move the device into another organization\u0027s product and either request can place it on another organization\u0027s deployment group. The device settings form in `NervesHubWeb.Components.DevicePage.SettingsTab` and the new device form in `NervesHubWeb.Live.Devices.New` reach the same changeset with the same effect.\n\n**2. Signing key and product forms.** `NervesHubWeb.Live.Org.SigningKeys` and `NervesHubWeb.Live.Products.New` build their parameters with `Enum.into(submitted, %{\"org_id\" =\u003e session_org_id})`, so a submitted `org_id` overrides the session\u0027s, and the key or product is created in the organization named by the client.\n\n**3. JITP settings.** `NervesHub.Devices.CACertificate.JITP.changeset/2` casts any `product_id`, through the API and the dashboard, so devices the certificate authority provisions are created in another organization\u0027s product.\n\n**4. Firmware delivery.** When a planted device connects, `NervesHub.ManagedDeployments` checks its deployment group membership by firmware version, platform and architecture only, all reported by the device, so it stays on the foreign group and is sent that group\u0027s current release with a download URL."
}
],
"value": "1. Device API and device forms. NervesHubWeb.API.DeviceController.create/2 sets org_id and product_id from the route, then hands the rest of the request to Device.changeset/2; update/2 hands the whole request over. The changeset casts org_id, product_id, deployment_id, deleted_at and the server-managed update state, so an update can move the device into another organization\u0027s product and either request can place it on another organization\u0027s deployment group. The device settings form in NervesHubWeb.Components.DevicePage.SettingsTab and the new device form in NervesHubWeb.Live.Devices.New reach the same changeset with the same effect.\n\n2. Signing key and product forms. NervesHubWeb.Live.Org.SigningKeys and NervesHubWeb.Live.Products.New build their parameters with Enum.into(submitted, %{\"org_id\" =\u003e session_org_id}), so a submitted org_id overrides the session\u0027s, and the key or product is created in the organization named by the client.\n\n3. JITP settings. NervesHub.Devices.CACertificate.JITP.changeset/2 casts any product_id, through the API and the dashboard, so devices the certificate authority provisions are created in another organization\u0027s product.\n\n4. Firmware delivery. When a planted device connects, NervesHub.ManagedDeployments checks its deployment group membership by firmware version, platform and architecture only, all reported by the device, so it stays on the foreign group and is sent that group\u0027s current release with a download URL."
}
]
}
},
"cveMetadata": {
"assignerOrgId": "6b3ad84c-e1a6-4bf7-a703-f496b71e49db",
"assignerShortName": "EEF",
"cveId": "CVE-2026-120730",
"datePublished": "2026-11-10T10:43:47.924Z",
"dateReserved": "2026-11-03T03:45:01.695Z",
"dateUpdated": "2026-11-12T17:46:28.444Z",
"state": "PUBLISHED"
},
"dataType": "CVE_RECORD",
"dataVersion": "5.2"
}
CVE-2026-120999 (GCVE-0-2026-120999)
Vulnerability from cvelistv5 – Published: 2026-11-10 10:43 – Updated: 2026-11-12 17:45
VLAI
EPSS
VEX
Title
Improper authentication lets Google sign-in and confirmation links sign users in to accounts registered with their email address by someone else
Summary
Improper Authentication vulnerability in Google sign-in and email confirmation in nerves-hub nerves_hub_web allows an attacker who registers another person's email address to share that person's account once they sign in. NervesHub.Accounts.update_or_create_user_from_ueberauth/1 signs a Google user in to any account carrying the same email that is not yet linked to a Google account, links it and marks the email confirmed, without the account's owner ever having proved they own the address. NervesHubWeb.SessionController.confirm/2 likewise signs in whoever follows a confirmation link. An attacker registers the address from the sign-up form, changes their own account's email to it, or registers it as an organization admin from an invite link, and keeps the password; when the address's owner signs in with Google or follows the confirmation link, they land in that account.
The Google path also creates accounts with registrations closed and never checks Google's email_verified claim, and the API's password sign-in in NervesHubWeb.API.UserController issues tokens to accounts whose email was never confirmed. The attacker gains only what the victim is granted afterwards, and nothing on accounts whose owners never use either path.
This issue affects nerves_hub: from 2.3.0 before 2.5.3.
Severity
SSVC
Exploitation: poc
Automatable: no
Technical Impact: total
CISA Coordinator · CISA-ADP (v2.0.3)
Decision recorded 2026-11-12 17:44 UTC
Assigner
References
5 references
| URL | Tags |
|---|---|
| https://github.com/nerves-hub/nerves_hub_web/secu… | vendor-advisoryrelated |
| https://cna.erlef.org/cves/CVE-2026-120999.html | related |
| https://osv.dev/vulnerability/EEF-CVE-2026-120999 | related |
| https://github.com/nerves-hub/nerves_hub_web/comm… | related |
| https://github.com/nerves-hub/nerves_hub_web/comm… | patch |
Impacted products
3 products
| Vendor | Product | Version | |
|---|---|---|---|
| nerves-hub | nerves_hub_web |
Affected:
2.3.0 , < 2.5.3
(other)
cpe:2.3:a:nerves-hub:nerves_hub_web:*:*:*:*:*:*:*:* |
|
| nerves-hub | nerves_hub_web |
Affected:
2.3.0 , < 2.5.3
(semver)
cpe:2.3:a:nerves-hub:nerves_hub_web:*:*:*:*:*:*:*:* |
|
| nerves-hub | nerves_hub_web |
Affected:
74982672ab914bd6f37c3bc0e24a06e3474fa030 , < 54dbf93c3bd323df08223a853c9afcf0b4dbf8e4
(git)
cpe:2.3:a:nerves-hub:nerves_hub_web:*:*:*:*:*:*:*:* |
Date Public
2026-11-10 10:43
{
"containers": {
"adp": [
{
"metrics": [
{
"other": {
"content": {
"id": "CVE-2026-120999",
"options": [
{
"Exploitation": "poc"
},
{
"Automatable": "no"
},
{
"Technical Impact": "total"
}
],
"role": "CISA Coordinator",
"timestamp": "2026-11-12T17:44:16.500445Z",
"version": "2.0.3"
},
"type": "ssvc"
}
}
],
"providerMetadata": {
"dateUpdated": "2026-11-12T17:45:38.857Z",
"orgId": "134c704f-9b21-4f2e-91b3-4a467353bcc0",
"shortName": "CISA-ADP"
},
"references": [
{
"tags": [
"exploit"
],
"url": "https://github.com/nerves-hub/nerves_hub_web/security/advisories/GHSA-r92p-gg92-gmwm"
}
],
"title": "CISA ADP Vulnrichment"
}
],
"cna": {
"affected": [
{
"collectionURL": "https://ghcr.io",
"cpes": [
"cpe:2.3:a:nerves-hub:nerves_hub_web:*:*:*:*:*:*:*:*"
],
"defaultStatus": "unaffected",
"modules": [
"\u0027Elixir.NervesHub.Accounts\u0027",
"\u0027Elixir.NervesHub.Accounts.User\u0027",
"\u0027Elixir.NervesHubWeb.SessionController\u0027",
"\u0027Elixir.NervesHubWeb.API.UserController\u0027",
"\u0027Elixir.NervesHubWeb.Live.Account\u0027",
"\u0027Elixir.NervesHubWeb.AccountController\u0027"
],
"packageName": "nerves-hub/nerves-hub",
"packageURL": "pkg:oci/nerves-hub?repository_url=ghcr.io%2Fnerves-hub%2Fnerves-hub",
"product": "nerves_hub_web",
"programFiles": [
"lib/nerves_hub/accounts.ex",
"lib/nerves_hub/accounts/user.ex",
"lib/nerves_hub_web/controllers/session_controller.ex",
"lib/nerves_hub_web/controllers/api/user_controller.ex",
"lib/nerves_hub_web/live/account.ex",
"lib/nerves_hub_web/controllers/account_controller.ex"
],
"programRoutines": [
{
"name": "\u0027Elixir.NervesHub.Accounts\u0027:update_or_create_user_from_ueberauth/1"
},
{
"name": "\u0027Elixir.NervesHub.Accounts\u0027:authenticate/2"
},
{
"name": "\u0027Elixir.NervesHub.Accounts.User\u0027:oauth_changeset/3"
},
{
"name": "\u0027Elixir.NervesHubWeb.SessionController\u0027:confirm/2"
},
{
"name": "\u0027Elixir.NervesHubWeb.API.UserController\u0027:auth/2"
},
{
"name": "\u0027Elixir.NervesHubWeb.API.UserController\u0027:login/2"
}
],
"vendor": "nerves-hub",
"versions": [
{
"lessThan": "2.5.3",
"status": "affected",
"version": "2.3.0",
"versionType": "other"
}
]
},
{
"cpes": [
"cpe:2.3:a:nerves-hub:nerves_hub_web:*:*:*:*:*:*:*:*"
],
"defaultStatus": "unaffected",
"modules": [
"\u0027Elixir.NervesHub.Accounts\u0027",
"\u0027Elixir.NervesHub.Accounts.User\u0027",
"\u0027Elixir.NervesHubWeb.SessionController\u0027",
"\u0027Elixir.NervesHubWeb.API.UserController\u0027",
"\u0027Elixir.NervesHubWeb.Live.Account\u0027",
"\u0027Elixir.NervesHubWeb.AccountController\u0027"
],
"packageName": "nerves_hub",
"packageURL": "pkg:otp/nerves_hub?repository_url=https:%2F%2Fgithub.com%2Fnerves-hub%2Fnerves_hub_web\u0026vcs_url=git%2Bhttps:%2F%2Fgithub.com%2Fnerves-hub%2Fnerves_hub_web.git",
"product": "nerves_hub_web",
"programFiles": [
"lib/nerves_hub/accounts.ex",
"lib/nerves_hub/accounts/user.ex",
"lib/nerves_hub_web/controllers/session_controller.ex",
"lib/nerves_hub_web/controllers/api/user_controller.ex",
"lib/nerves_hub_web/live/account.ex",
"lib/nerves_hub_web/controllers/account_controller.ex"
],
"programRoutines": [
{
"name": "\u0027Elixir.NervesHub.Accounts\u0027:update_or_create_user_from_ueberauth/1"
},
{
"name": "\u0027Elixir.NervesHub.Accounts\u0027:authenticate/2"
},
{
"name": "\u0027Elixir.NervesHub.Accounts.User\u0027:oauth_changeset/3"
},
{
"name": "\u0027Elixir.NervesHubWeb.SessionController\u0027:confirm/2"
},
{
"name": "\u0027Elixir.NervesHubWeb.API.UserController\u0027:auth/2"
},
{
"name": "\u0027Elixir.NervesHubWeb.API.UserController\u0027:login/2"
}
],
"repo": "https://github.com/nerves-hub/nerves_hub_web",
"vendor": "nerves-hub",
"versions": [
{
"lessThan": "2.5.3",
"status": "affected",
"version": "2.3.0",
"versionType": "semver"
}
]
},
{
"collectionURL": "https://github.com",
"cpes": [
"cpe:2.3:a:nerves-hub:nerves_hub_web:*:*:*:*:*:*:*:*"
],
"defaultStatus": "unaffected",
"modules": [
"\u0027Elixir.NervesHub.Accounts\u0027",
"\u0027Elixir.NervesHub.Accounts.User\u0027",
"\u0027Elixir.NervesHubWeb.SessionController\u0027",
"\u0027Elixir.NervesHubWeb.API.UserController\u0027",
"\u0027Elixir.NervesHubWeb.Live.Account\u0027",
"\u0027Elixir.NervesHubWeb.AccountController\u0027"
],
"packageName": "nerves-hub/nerves_hub_web",
"packageURL": "pkg:github/nerves-hub/nerves_hub_web",
"product": "nerves_hub_web",
"programFiles": [
"lib/nerves_hub/accounts.ex",
"lib/nerves_hub/accounts/user.ex",
"lib/nerves_hub_web/controllers/session_controller.ex",
"lib/nerves_hub_web/controllers/api/user_controller.ex",
"lib/nerves_hub_web/live/account.ex",
"lib/nerves_hub_web/controllers/account_controller.ex"
],
"programRoutines": [
{
"name": "\u0027Elixir.NervesHub.Accounts\u0027:update_or_create_user_from_ueberauth/1"
},
{
"name": "\u0027Elixir.NervesHub.Accounts\u0027:authenticate/2"
},
{
"name": "\u0027Elixir.NervesHub.Accounts.User\u0027:oauth_changeset/3"
},
{
"name": "\u0027Elixir.NervesHubWeb.SessionController\u0027:confirm/2"
},
{
"name": "\u0027Elixir.NervesHubWeb.API.UserController\u0027:auth/2"
},
{
"name": "\u0027Elixir.NervesHubWeb.API.UserController\u0027:login/2"
}
],
"repo": "https://github.com/nerves-hub/nerves_hub_web",
"vendor": "nerves-hub",
"versions": [
{
"lessThan": "54dbf93c3bd323df08223a853c9afcf0b4dbf8e4",
"status": "affected",
"version": "74982672ab914bd6f37c3bc0e24a06e3474fa030",
"versionType": "git"
}
]
}
],
"cpeApplicability": [
{
"nodes": [
{
"cpeMatch": [
{
"criteria": "cpe:2.3:a:nerves-hub:nerves_hub_web:*:*:*:*:*:*:*:*",
"versionEndExcluding": "2.5.3",
"versionStartIncluding": "2.3.0",
"vulnerable": true
}
],
"negate": false,
"operator": "OR"
}
],
"operator": "AND"
}
],
"credits": [
{
"lang": "en",
"type": "remediation developer",
"value": "Josh Kalderimis"
},
{
"lang": "en",
"type": "coordinator",
"value": "Jonatan M\u00e4nnchen / EEF"
}
],
"dateAssigned": "2026-11-09T23:11:54.000Z",
"datePublic": "2026-11-10T10:43:39.000Z",
"descriptions": [
{
"lang": "en",
"supportingMedia": [
{
"base64": false,
"type": "text/html",
"value": "\u003cp\u003eImproper Authentication vulnerability in Google sign-in and email confirmation in nerves-hub nerves_hub_web allows an attacker who registers another person\u0027s email address to share that person\u0027s account once they sign in. \u003ccode\u003eNervesHub.Accounts.update_or_create_user_from_ueberauth/1\u003c/code\u003e signs a Google user in to any account carrying the same email that is not yet linked to a Google account, links it and marks the email confirmed, without the account\u0027s owner ever having proved they own the address. \u003ccode\u003eNervesHubWeb.SessionController.confirm/2\u003c/code\u003e likewise signs in whoever follows a confirmation link. An attacker registers the address from the sign-up form, changes their own account\u0027s email to it, or registers it as an organization admin from an invite link, and keeps the password; when the address\u0027s owner signs in with Google or follows the confirmation link, they land in that account.\u003c/p\u003e\n\u003cp\u003eThe Google path also creates accounts with registrations closed and never checks Google\u0027s \u003ccode\u003eemail_verified\u003c/code\u003e claim, and the API\u0027s password sign-in in \u003ccode\u003eNervesHubWeb.API.UserController\u003c/code\u003e issues tokens to accounts whose email was never confirmed. The attacker gains only what the victim is granted afterwards, and nothing on accounts whose owners never use either path.\u003c/p\u003e\n\u003cp\u003eThis issue affects nerves_hub: from 2.3.0 before 2.5.3.\u003c/p\u003e"
},
{
"base64": false,
"type": "text/markdown",
"value": "Improper Authentication vulnerability in Google sign-in and email confirmation in nerves-hub nerves_hub_web allows an attacker who registers another person\u0027s email address to share that person\u0027s account once they sign in. `NervesHub.Accounts.update_or_create_user_from_ueberauth/1` signs a Google user in to any account carrying the same email that is not yet linked to a Google account, links it and marks the email confirmed, without the account\u0027s owner ever having proved they own the address. `NervesHubWeb.SessionController.confirm/2` likewise signs in whoever follows a confirmation link. An attacker registers the address from the sign-up form, changes their own account\u0027s email to it, or registers it as an organization admin from an invite link, and keeps the password; when the address\u0027s owner signs in with Google or follows the confirmation link, they land in that account.\n\nThe Google path also creates accounts with registrations closed and never checks Google\u0027s `email_verified` claim, and the API\u0027s password sign-in in `NervesHubWeb.API.UserController` issues tokens to accounts whose email was never confirmed. The attacker gains only what the victim is granted afterwards, and nothing on accounts whose owners never use either path.\n\nThis issue affects nerves_hub: from 2.3.0 before 2.5.3."
}
],
"value": "Improper Authentication vulnerability in Google sign-in and email confirmation in nerves-hub nerves_hub_web allows an attacker who registers another person\u0027s email address to share that person\u0027s account once they sign in. NervesHub.Accounts.update_or_create_user_from_ueberauth/1 signs a Google user in to any account carrying the same email that is not yet linked to a Google account, links it and marks the email confirmed, without the account\u0027s owner ever having proved they own the address. NervesHubWeb.SessionController.confirm/2 likewise signs in whoever follows a confirmation link. An attacker registers the address from the sign-up form, changes their own account\u0027s email to it, or registers it as an organization admin from an invite link, and keeps the password; when the address\u0027s owner signs in with Google or follows the confirmation link, they land in that account.\n\nThe Google path also creates accounts with registrations closed and never checks Google\u0027s email_verified claim, and the API\u0027s password sign-in in NervesHubWeb.API.UserController issues tokens to accounts whose email was never confirmed. The attacker gains only what the victim is granted afterwards, and nothing on accounts whose owners never use either path.\n\nThis issue affects nerves_hub: from 2.3.0 before 2.5.3."
}
],
"impacts": [
{
"capecId": "CAPEC-114",
"descriptions": [
{
"lang": "en",
"supportingMedia": [
{
"base64": false,
"type": "text/html",
"value": "\u003cp\u003eAn attacker who registers someone else\u0027s email address before that person first signs in with Google, or before they follow a confirmation link, ends up sharing that person\u0027s account: the attacker keeps signing in with the password they chose and sees and does whatever the victim can, in every organization the victim joins, at the victim\u0027s role. On an account linked to Google the victim cannot reset the password to lock the attacker out.\u003c/p\u003e"
},
{
"base64": false,
"type": "text/markdown",
"value": "An attacker who registers someone else\u0027s email address before that person first signs in with Google, or before they follow a confirmation link, ends up sharing that person\u0027s account: the attacker keeps signing in with the password they chose and sees and does whatever the victim can, in every organization the victim joins, at the victim\u0027s role. On an account linked to Google the victim cannot reset the password to lock the attacker out."
}
],
"value": "An attacker who registers someone else\u0027s email address before that person first signs in with Google, or before they follow a confirmation link, ends up sharing that person\u0027s account: the attacker keeps signing in with the password they chose and sees and does whatever the victim can, in every organization the victim joins, at the victim\u0027s role. On an account linked to Google the victim cannot reset the password to lock the attacker out."
}
]
}
],
"metrics": [
{
"cvssV4_0": {
"Automatable": "NOT_DEFINED",
"Recovery": "NOT_DEFINED",
"Safety": "NOT_DEFINED",
"attackComplexity": "LOW",
"attackRequirements": "PRESENT",
"attackVector": "NETWORK",
"baseScore": 7.4,
"baseSeverity": "HIGH",
"privilegesRequired": "NONE",
"providerUrgency": "NOT_DEFINED",
"subAvailabilityImpact": "NONE",
"subConfidentialityImpact": "NONE",
"subIntegrityImpact": "NONE",
"userInteraction": "ACTIVE",
"valueDensity": "NOT_DEFINED",
"vectorString": "CVSS:4.0/AV:N/AC:L/AT:P/PR:N/UI:A/VC:H/VI:H/VA:N/SC:N/SI:N/SA:N",
"version": "4.0",
"vulnAvailabilityImpact": "NONE",
"vulnConfidentialityImpact": "HIGH",
"vulnIntegrityImpact": "HIGH",
"vulnerabilityResponseEffort": "NOT_DEFINED"
},
"format": "CVSS",
"scenarios": [
{
"lang": "en",
"value": "GENERAL"
}
]
}
],
"problemTypes": [
{
"descriptions": [
{
"cweId": "CWE-287",
"description": "CWE-287 Improper Authentication",
"lang": "en",
"type": "CWE"
}
]
},
{
"descriptions": [
{
"cweId": "CWE-345",
"description": "CWE-345 Insufficient Verification of Data Authenticity",
"lang": "en",
"type": "CWE"
}
]
}
],
"providerMetadata": {
"dateUpdated": "2026-11-11T20:10:46.282Z",
"orgId": "6b3ad84c-e1a6-4bf7-a703-f496b71e49db",
"shortName": "EEF"
},
"references": [
{
"name": "Google sign-in and email confirmation links sign users in to accounts other people registered with their email address",
"tags": [
"vendor-advisory",
"related"
],
"url": "https://github.com/nerves-hub/nerves_hub_web/security/advisories/GHSA-r92p-gg92-gmwm"
},
{
"name": "EEF CNA record for CVE-2026-120999",
"tags": [
"related"
],
"url": "https://cna.erlef.org/cves/CVE-2026-120999.html"
},
{
"name": "OSV record EEF-CVE-2026-120999",
"tags": [
"related"
],
"url": "https://osv.dev/vulnerability/EEF-CVE-2026-120999"
},
{
"name": "Introducing commit 7498267 in nerves-hub/nerves_hub_web",
"tags": [
"related"
],
"url": "https://github.com/nerves-hub/nerves_hub_web/commit/74982672ab914bd6f37c3bc0e24a06e3474fa030"
},
{
"name": "Fix commit 54dbf93 in nerves-hub/nerves_hub_web",
"tags": [
"patch"
],
"url": "https://github.com/nerves-hub/nerves_hub_web/commit/54dbf93c3bd323df08223a853c9afcf0b4dbf8e4"
}
],
"solutions": [
{
"lang": "en",
"supportingMedia": [
{
"base64": false,
"type": "text/html",
"value": "\u003cp\u003eUpgrading does not undo links already made. Review the \u003ccode\u003eusers\u003c/code\u003e rows that carry both a \u003ccode\u003epassword_hash\u003c/code\u003e and a \u003ccode\u003egoogle_id\u003c/code\u003e; most are one person signing in both ways, but an account registered for someone else\u0027s address was usually created well before its first Google sign-in, so compare \u003ccode\u003einserted_at\u003c/code\u003e with \u003ccode\u003egoogle_last_synced_at\u003c/code\u003e. For one its owner did not set up, set its \u003ccode\u003egoogle_id\u003c/code\u003e to \u003ccode\u003eNULL\u003c/code\u003e, delete its \u003ccode\u003euser_tokens\u003c/code\u003e rows to end its sessions and revoke its API tokens, and have the address\u0027s owner reset the password. An account taken through a confirmation link looks the same as one its owner set up; its owner can end anyone else\u0027s access by resetting the password, which ends every session and revokes every API token. Before upgrading, check how your instance lets people sign up: \u003ccode\u003eSIGN_UP_METHODS\u003c/code\u003e replaces \u003ccode\u003eOPEN_FOR_REGISTRATIONS\u003c/code\u003e, and instances that relied on Google sign-in to create accounts while registrations were off need \u003ccode\u003eSIGN_UP_METHODS=google\u003c/code\u003e.\u003c/p\u003e"
},
{
"base64": false,
"type": "text/markdown",
"value": "Upgrading does not undo links already made. Review the `users` rows that carry both a `password_hash` and a `google_id`; most are one person signing in both ways, but an account registered for someone else\u0027s address was usually created well before its first Google sign-in, so compare `inserted_at` with `google_last_synced_at`. For one its owner did not set up, set its `google_id` to `NULL`, delete its `user_tokens` rows to end its sessions and revoke its API tokens, and have the address\u0027s owner reset the password. An account taken through a confirmation link looks the same as one its owner set up; its owner can end anyone else\u0027s access by resetting the password, which ends every session and revokes every API token. Before upgrading, check how your instance lets people sign up: `SIGN_UP_METHODS` replaces `OPEN_FOR_REGISTRATIONS`, and instances that relied on Google sign-in to create accounts while registrations were off need `SIGN_UP_METHODS=google`."
}
],
"value": "Upgrading does not undo links already made. Review the users rows that carry both a password_hash and a google_id; most are one person signing in both ways, but an account registered for someone else\u0027s address was usually created well before its first Google sign-in, so compare inserted_at with google_last_synced_at. For one its owner did not set up, set its google_id to NULL, delete its user_tokens rows to end its sessions and revoke its API tokens, and have the address\u0027s owner reset the password. An account taken through a confirmation link looks the same as one its owner set up; its owner can end anyone else\u0027s access by resetting the password, which ends every session and revokes every API token. Before upgrading, check how your instance lets people sign up: SIGN_UP_METHODS replaces OPEN_FOR_REGISTRATIONS, and instances that relied on Google sign-in to create accounts while registrations were off need SIGN_UP_METHODS=google."
}
],
"source": {
"discovery": "UNKNOWN"
},
"title": "Improper authentication lets Google sign-in and confirmation links sign users in to accounts registered with their email address by someone else",
"workarounds": [
{
"lang": "en",
"supportingMedia": [
{
"base64": false,
"type": "text/html",
"value": "\u003cp\u003eTurn off Google sign-in by unsetting \u003ccode\u003eGOOGLE_CLIENT_ID\u003c/code\u003e until the upgrade; users who only sign in with Google cannot sign in while it is off. This does not close the confirmation link path. Turning off registrations stops strangers using the sign-up form, but not an organization admin registering from an invite link.\u003c/p\u003e"
},
{
"base64": false,
"type": "text/markdown",
"value": "Turn off Google sign-in by unsetting `GOOGLE_CLIENT_ID` until the upgrade; users who only sign in with Google cannot sign in while it is off. This does not close the confirmation link path. Turning off registrations stops strangers using the sign-up form, but not an organization admin registering from an invite link."
}
],
"value": "Turn off Google sign-in by unsetting GOOGLE_CLIENT_ID until the upgrade; users who only sign in with Google cannot sign in while it is off. This does not close the confirmation link path. Turning off registrations stops strangers using the sign-up form, but not an organization admin registering from an invite link."
}
],
"x_proofOfConcept": [
{
"lang": "en",
"supportingMedia": [
{
"base64": false,
"type": "text/html",
"value": "\u003cp\u003eWith Google sign-in turned on and registrations open:\u003c/p\u003e\n\u003col\u003e\n\u003cli\u003eRegister an account with \u003ccode\u003evictim@example.com\u003c/code\u003e and a password you choose. Do not confirm it.\u003c/li\u003e\n\u003cli\u003eThe owner of \u003ccode\u003evictim@example.com\u003c/code\u003e signs in to NervesHub with Google for the first time. They are signed in to your account, and its email is now marked confirmed.\u003c/li\u003e\n\u003cli\u003eAn organization invites \u003ccode\u003evictim@example.com\u003c/code\u003e and the victim accepts while signed in.\u003c/li\u003e\n\u003cli\u003eSign in with \u003ccode\u003evictim@example.com\u003c/code\u003e and your password. You are a member of the organization at the role it gave the victim.\u003c/li\u003e\n\u003c/ol\u003e\n\u003cp\u003eWithout Google sign-in, as an admin of an organization:\u003c/p\u003e\n\u003col\u003e\n\u003cli\u003eInvite \u003ccode\u003evictim@example.com\u003c/code\u003e, copy the invite link from the members page, and register from it with a password you choose.\u003c/li\u003e\n\u003cli\u003eTry to sign in with \u003ccode\u003evictim@example.com\u003c/code\u003e and your password. NervesHub refuses, since the email is not confirmed, and emails \u003ccode\u003evictim@example.com\u003c/code\u003e a confirmation link.\u003c/li\u003e\n\u003cli\u003eThe victim follows the link and is signed in to your account. Keep using your session or sign in with your password.\u003c/li\u003e\n\u003c/ol\u003e"
},
{
"base64": false,
"type": "text/markdown",
"value": "With Google sign-in turned on and registrations open:\n\n1. Register an account with `victim@example.com` and a password you choose. Do not confirm it.\n2. The owner of `victim@example.com` signs in to NervesHub with Google for the first time. They are signed in to your account, and its email is now marked confirmed.\n3. An organization invites `victim@example.com` and the victim accepts while signed in.\n4. Sign in with `victim@example.com` and your password. You are a member of the organization at the role it gave the victim.\n\nWithout Google sign-in, as an admin of an organization:\n\n1. Invite `victim@example.com`, copy the invite link from the members page, and register from it with a password you choose.\n2. Try to sign in with `victim@example.com` and your password. NervesHub refuses, since the email is not confirmed, and emails `victim@example.com` a confirmation link.\n3. The victim follows the link and is signed in to your account. Keep using your session or sign in with your password."
}
],
"value": "With Google sign-in turned on and registrations open:\n\n* Register an account with victim@example.com and a password you choose. Do not confirm it.\n* The owner of victim@example.com signs in to NervesHub with Google for the first time. They are signed in to your account, and its email is now marked confirmed.\n* An organization invites victim@example.com and the victim accepts while signed in.\n* Sign in with victim@example.com and your password. You are a member of the organization at the role it gave the victim.\n\nWithout Google sign-in, as an admin of an organization:\n\n* Invite victim@example.com, copy the invite link from the members page, and register from it with a password you choose.\n* Try to sign in with victim@example.com and your password. NervesHub refuses, since the email is not confirmed, and emails victim@example.com a confirmation link.\n* The victim follows the link and is signed in to your account. Keep using your session or sign in with your password."
}
]
}
},
"cveMetadata": {
"assignerOrgId": "6b3ad84c-e1a6-4bf7-a703-f496b71e49db",
"assignerShortName": "EEF",
"cveId": "CVE-2026-120999",
"datePublished": "2026-11-10T10:43:38.941Z",
"dateReserved": "2026-11-09T22:47:57.298Z",
"dateUpdated": "2026-11-12T17:45:38.857Z",
"state": "PUBLISHED"
},
"dataType": "CVE_RECORD",
"dataVersion": "5.2"
}
CVE-2026-120295 (GCVE-0-2026-120295)
Vulnerability from cvelistv5 – Published: 2026-11-10 10:43 – Updated: 2026-11-12 17:43
VLAI
EPSS
VEX
Title
Incorrect authorization in the devices page bulk move allows view-role members to move devices into another organization and receive its firmware
Summary
Incorrect Authorization vulnerability in the devices page bulk move in nerves-hub nerves_hub_web allows an authenticated user with the manage role in one organization and only the view role in another to move devices into the second organization's product via the "Move device(s) to product" action. The target-product event in NervesHubWeb.Live.Devices.Index looks the product up with NervesHub.Products.get_by_id/2, which checks that the user is a member of the product's organization at any role, and move-devices-product then changes each selected device's organization and product. The device API's move requires manage in the target organization, and an earlier fix added the check on the organization the devices leave but not on the one they enter.
A moved device keeps its key and certificate, joins the target organization's matching deployment groups, and is sent their firmware with a download URL, which the view role cannot otherwise obtain. The user cannot change or control devices that already belong to the other organization.
This issue affects nerves_hub: from 1.0.0 before 2.5.3.
Severity
SSVC
Exploitation: poc
Automatable: no
Technical Impact: partial
CISA Coordinator · CISA-ADP (v2.0.3)
Decision recorded 2026-11-12 17:43 UTC
CWE
- CWE-863 - Incorrect Authorization
Assigner
References
6 references
| URL | Tags |
|---|---|
| https://github.com/nerves-hub/nerves_hub_web/secu… | vendor-advisoryrelated |
| https://cna.erlef.org/cves/CVE-2026-120295.html | related |
| https://osv.dev/vulnerability/EEF-CVE-2026-120295 | related |
| https://github.com/nerves-hub/nerves_hub_web/comm… | related |
| https://github.com/nerves-hub/nerves_hub_web/comm… | related |
| https://github.com/nerves-hub/nerves_hub_web/comm… | patch |
Impacted products
3 products
| Vendor | Product | Version | |
|---|---|---|---|
| nerves-hub | nerves_hub_web |
Affected:
1.0.0 , < 2.5.3
(other)
cpe:2.3:a:nerves-hub:nerves_hub_web:*:*:*:*:*:*:*:* |
|
| nerves-hub | nerves_hub_web |
Affected:
1.0.0 , < 2.5.3
(semver)
cpe:2.3:a:nerves-hub:nerves_hub_web:*:*:*:*:*:*:*:* |
|
| nerves-hub | nerves_hub_web |
Affected:
adaeefdb7a835525482588f43332ef988cc448c7 , < *
(git)
Affected: 79743ef08f7f801617b653fd09f37c01ed9b4be0 , < 1bc8be875ab6b1eb904e5ff60c8a1421c5c700fa (git) cpe:2.3:a:nerves-hub:nerves_hub_web:*:*:*:*:*:*:*:* |
Date Public
2026-11-10 10:43
{
"containers": {
"adp": [
{
"metrics": [
{
"other": {
"content": {
"id": "CVE-2026-120295",
"options": [
{
"Exploitation": "poc"
},
{
"Automatable": "no"
},
{
"Technical Impact": "partial"
}
],
"role": "CISA Coordinator",
"timestamp": "2026-11-12T17:43:24.355706Z",
"version": "2.0.3"
},
"type": "ssvc"
}
}
],
"providerMetadata": {
"dateUpdated": "2026-11-12T17:43:44.771Z",
"orgId": "134c704f-9b21-4f2e-91b3-4a467353bcc0",
"shortName": "CISA-ADP"
},
"references": [
{
"tags": [
"exploit"
],
"url": "https://github.com/nerves-hub/nerves_hub_web/security/advisories/GHSA-2j7r-q3vm-8j34"
}
],
"title": "CISA ADP Vulnrichment"
}
],
"cna": {
"affected": [
{
"collectionURL": "https://ghcr.io",
"cpes": [
"cpe:2.3:a:nerves-hub:nerves_hub_web:*:*:*:*:*:*:*:*"
],
"defaultStatus": "unaffected",
"modules": [
"\u0027Elixir.NervesHubWeb.Live.Devices.Index\u0027",
"\u0027Elixir.NervesHub.Products\u0027",
"\u0027Elixir.NervesHubWeb.Components.BulkActionsSidebar\u0027"
],
"packageName": "nerves-hub/nerves-hub",
"packageURL": "pkg:oci/nerves-hub?repository_url=ghcr.io%2Fnerves-hub%2Fnerves-hub",
"product": "nerves_hub_web",
"programFiles": [
"lib/nerves_hub_web/live/devices/index.ex",
"lib/nerves_hub/products.ex",
"lib/nerves_hub_web/components/bulk_actions_sidebar.ex"
],
"programRoutines": [
{
"name": "\u0027Elixir.NervesHubWeb.Live.Devices.Index\u0027:handle_event/3"
},
{
"name": "\u0027Elixir.NervesHub.Products\u0027:get_by_id/2"
}
],
"vendor": "nerves-hub",
"versions": [
{
"lessThan": "2.5.3",
"status": "affected",
"version": "1.0.0",
"versionType": "other"
}
]
},
{
"cpes": [
"cpe:2.3:a:nerves-hub:nerves_hub_web:*:*:*:*:*:*:*:*"
],
"defaultStatus": "unaffected",
"modules": [
"\u0027Elixir.NervesHubWeb.Live.Devices.Index\u0027",
"\u0027Elixir.NervesHub.Products\u0027",
"\u0027Elixir.NervesHubWeb.Components.BulkActionsSidebar\u0027"
],
"packageName": "nerves_hub",
"packageURL": "pkg:otp/nerves_hub?repository_url=https:%2F%2Fgithub.com%2Fnerves-hub%2Fnerves_hub_web\u0026vcs_url=git%2Bhttps:%2F%2Fgithub.com%2Fnerves-hub%2Fnerves_hub_web.git",
"product": "nerves_hub_web",
"programFiles": [
"lib/nerves_hub_web/live/devices/index.ex",
"lib/nerves_hub/products.ex",
"lib/nerves_hub_web/components/bulk_actions_sidebar.ex"
],
"programRoutines": [
{
"name": "\u0027Elixir.NervesHubWeb.Live.Devices.Index\u0027:handle_event/3"
},
{
"name": "\u0027Elixir.NervesHub.Products\u0027:get_by_id/2"
}
],
"repo": "https://github.com/nerves-hub/nerves_hub_web",
"vendor": "nerves-hub",
"versions": [
{
"lessThan": "2.5.3",
"status": "affected",
"version": "1.0.0",
"versionType": "semver"
}
]
},
{
"collectionURL": "https://github.com",
"cpes": [
"cpe:2.3:a:nerves-hub:nerves_hub_web:*:*:*:*:*:*:*:*"
],
"defaultStatus": "unaffected",
"modules": [
"\u0027Elixir.NervesHubWeb.Live.Devices.Index\u0027",
"\u0027Elixir.NervesHub.Products\u0027",
"\u0027Elixir.NervesHubWeb.Components.BulkActionsSidebar\u0027"
],
"packageName": "nerves-hub/nerves_hub_web",
"packageURL": "pkg:github/nerves-hub/nerves_hub_web",
"product": "nerves_hub_web",
"programFiles": [
"lib/nerves_hub_web/live/devices/index.ex",
"lib/nerves_hub/products.ex",
"lib/nerves_hub_web/components/bulk_actions_sidebar.ex"
],
"programRoutines": [
{
"name": "\u0027Elixir.NervesHubWeb.Live.Devices.Index\u0027:handle_event/3"
},
{
"name": "\u0027Elixir.NervesHub.Products\u0027:get_by_id/2"
}
],
"repo": "https://github.com/nerves-hub/nerves_hub_web",
"vendor": "nerves-hub",
"versions": [
{
"lessThan": "*",
"status": "affected",
"version": "adaeefdb7a835525482588f43332ef988cc448c7",
"versionType": "git"
},
{
"lessThan": "1bc8be875ab6b1eb904e5ff60c8a1421c5c700fa",
"status": "affected",
"version": "79743ef08f7f801617b653fd09f37c01ed9b4be0",
"versionType": "git"
}
]
}
],
"cpeApplicability": [
{
"nodes": [
{
"cpeMatch": [
{
"criteria": "cpe:2.3:a:nerves-hub:nerves_hub_web:*:*:*:*:*:*:*:*",
"versionEndExcluding": "2.5.3",
"versionStartIncluding": "1.0.0",
"vulnerable": true
}
],
"negate": false,
"operator": "OR"
}
],
"operator": "AND"
}
],
"credits": [
{
"lang": "en",
"type": "finder",
"value": "arbor-s"
},
{
"lang": "en",
"type": "reporter",
"value": "arbor-s"
},
{
"lang": "en",
"type": "remediation developer",
"value": "Josh Kalderimis"
},
{
"lang": "en",
"type": "coordinator",
"value": "Jonatan M\u00e4nnchen / EEF"
}
],
"dateAssigned": "2026-11-09T22:35:49.000Z",
"datePublic": "2026-11-10T10:43:26.000Z",
"descriptions": [
{
"lang": "en",
"supportingMedia": [
{
"base64": false,
"type": "text/html",
"value": "\u003cp\u003eIncorrect Authorization vulnerability in the devices page bulk move in nerves-hub nerves_hub_web allows an authenticated user with the \u003ccode\u003emanage\u003c/code\u003e role in one organization and only the \u003ccode\u003eview\u003c/code\u003e role in another to move devices into the second organization\u0027s product via the \"Move device(s) to product\" action. The \u003ccode\u003etarget-product\u003c/code\u003e event in \u003ccode\u003eNervesHubWeb.Live.Devices.Index\u003c/code\u003e looks the product up with \u003ccode\u003eNervesHub.Products.get_by_id/2\u003c/code\u003e, which checks that the user is a member of the product\u0027s organization at any role, and \u003ccode\u003emove-devices-product\u003c/code\u003e then changes each selected device\u0027s organization and product. The device API\u0027s move requires \u003ccode\u003emanage\u003c/code\u003e in the target organization, and an earlier fix added the check on the organization the devices leave but not on the one they enter.\u003c/p\u003e\n\u003cp\u003eA moved device keeps its key and certificate, joins the target organization\u0027s matching deployment groups, and is sent their firmware with a download URL, which the \u003ccode\u003eview\u003c/code\u003e role cannot otherwise obtain. The user cannot change or control devices that already belong to the other organization.\u003c/p\u003e\n\u003cp\u003eThis issue affects nerves_hub: from 1.0.0 before 2.5.3.\u003c/p\u003e"
},
{
"base64": false,
"type": "text/markdown",
"value": "Incorrect Authorization vulnerability in the devices page bulk move in nerves-hub nerves_hub_web allows an authenticated user with the `manage` role in one organization and only the `view` role in another to move devices into the second organization\u0027s product via the \"Move device(s) to product\" action. The `target-product` event in `NervesHubWeb.Live.Devices.Index` looks the product up with `NervesHub.Products.get_by_id/2`, which checks that the user is a member of the product\u0027s organization at any role, and `move-devices-product` then changes each selected device\u0027s organization and product. The device API\u0027s move requires `manage` in the target organization, and an earlier fix added the check on the organization the devices leave but not on the one they enter.\n\nA moved device keeps its key and certificate, joins the target organization\u0027s matching deployment groups, and is sent their firmware with a download URL, which the `view` role cannot otherwise obtain. The user cannot change or control devices that already belong to the other organization.\n\nThis issue affects nerves_hub: from 1.0.0 before 2.5.3."
}
],
"value": "Incorrect Authorization vulnerability in the devices page bulk move in nerves-hub nerves_hub_web allows an authenticated user with the manage role in one organization and only the view role in another to move devices into the second organization\u0027s product via the \"Move device(s) to product\" action. The target-product event in NervesHubWeb.Live.Devices.Index looks the product up with NervesHub.Products.get_by_id/2, which checks that the user is a member of the product\u0027s organization at any role, and move-devices-product then changes each selected device\u0027s organization and product. The device API\u0027s move requires manage in the target organization, and an earlier fix added the check on the organization the devices leave but not on the one they enter.\n\nA moved device keeps its key and certificate, joins the target organization\u0027s matching deployment groups, and is sent their firmware with a download URL, which the view role cannot otherwise obtain. The user cannot change or control devices that already belong to the other organization.\n\nThis issue affects nerves_hub: from 1.0.0 before 2.5.3."
}
],
"impacts": [
{
"capecId": "CAPEC-122",
"descriptions": [
{
"lang": "en",
"supportingMedia": [
{
"base64": false,
"type": "text/html",
"value": "\u003cp\u003eA user who manages devices in one organization and holds only the view role in another can move their own devices into the second organization. The devices join its matching deployment groups and receive its firmware, which the view role cannot otherwise download, and they report health, logs and alarms into that organization. Devices already belonging to the other organization are not affected.\u003c/p\u003e"
},
{
"base64": false,
"type": "text/markdown",
"value": "A user who manages devices in one organization and holds only the view role in another can move their own devices into the second organization. The devices join its matching deployment groups and receive its firmware, which the view role cannot otherwise download, and they report health, logs and alarms into that organization. Devices already belonging to the other organization are not affected."
}
],
"value": "A user who manages devices in one organization and holds only the view role in another can move their own devices into the second organization. The devices join its matching deployment groups and receive its firmware, which the view role cannot otherwise download, and they report health, logs and alarms into that organization. Devices already belonging to the other organization are not affected."
}
]
}
],
"metrics": [
{
"cvssV4_0": {
"Automatable": "NOT_DEFINED",
"Recovery": "NOT_DEFINED",
"Safety": "NOT_DEFINED",
"attackComplexity": "LOW",
"attackRequirements": "NONE",
"attackVector": "NETWORK",
"baseScore": 7.1,
"baseSeverity": "HIGH",
"privilegesRequired": "LOW",
"providerUrgency": "NOT_DEFINED",
"subAvailabilityImpact": "NONE",
"subConfidentialityImpact": "NONE",
"subIntegrityImpact": "NONE",
"userInteraction": "NONE",
"valueDensity": "NOT_DEFINED",
"vectorString": "CVSS:4.0/AV:N/AC:L/AT:N/PR:L/UI:N/VC:H/VI:L/VA:N/SC:N/SI:N/SA:N",
"version": "4.0",
"vulnAvailabilityImpact": "NONE",
"vulnConfidentialityImpact": "HIGH",
"vulnIntegrityImpact": "LOW",
"vulnerabilityResponseEffort": "NOT_DEFINED"
},
"format": "CVSS",
"scenarios": [
{
"lang": "en",
"value": "GENERAL"
}
]
}
],
"problemTypes": [
{
"descriptions": [
{
"cweId": "CWE-863",
"description": "CWE-863 Incorrect Authorization",
"lang": "en",
"type": "CWE"
}
]
}
],
"providerMetadata": {
"dateUpdated": "2026-11-11T19:34:34.547Z",
"orgId": "6b3ad84c-e1a6-4bf7-a703-f496b71e49db",
"shortName": "EEF"
},
"references": [
{
"name": "Users with the \u2018view\u2019 role in an organization can move devices into it",
"tags": [
"vendor-advisory",
"related"
],
"url": "https://github.com/nerves-hub/nerves_hub_web/security/advisories/GHSA-2j7r-q3vm-8j34"
},
{
"name": "EEF CNA record for CVE-2026-120295",
"tags": [
"related"
],
"url": "https://cna.erlef.org/cves/CVE-2026-120295.html"
},
{
"name": "OSV record EEF-CVE-2026-120295",
"tags": [
"related"
],
"url": "https://osv.dev/vulnerability/EEF-CVE-2026-120295"
},
{
"name": "Introducing commit adaeefd in nerves-hub/nerves_hub_web",
"tags": [
"related"
],
"url": "https://github.com/nerves-hub/nerves_hub_web/commit/adaeefdb7a835525482588f43332ef988cc448c7"
},
{
"name": "Introducing commit 79743ef in nerves-hub/nerves_hub_web",
"tags": [
"related"
],
"url": "https://github.com/nerves-hub/nerves_hub_web/commit/79743ef08f7f801617b653fd09f37c01ed9b4be0"
},
{
"name": "Fix commit 1bc8be8 in nerves-hub/nerves_hub_web",
"tags": [
"patch"
],
"url": "https://github.com/nerves-hub/nerves_hub_web/commit/1bc8be875ab6b1eb904e5ff60c8a1421c5c700fa"
}
],
"solutions": [
{
"lang": "en",
"supportingMedia": [
{
"base64": false,
"type": "text/html",
"value": "\u003cp\u003eEvery move is recorded in the target product\u0027s audit log as \"User moved device to : \u003c/p\u003e"
},
{
"base64": false,
"type": "text/markdown",
"value": "Every move is recorded in the target product\u0027s audit log as \"User \u003cname\u003e moved device \u003cidentifier\u003e to \u003corg\u003e : \u003cproduct\u003e\". After upgrading, review the `audit_logs` rows with resource type `NervesHub.Products.Product` and a description containing \"moved device\" whose actor does not hold the `manage` or `admin` role in that organization, and move or remove the devices they brought in. Roles may have changed since a move, and entries older than the retention period are gone when `TRUNCATE_AUDIT_LOGS_ENABLED` is on."
}
],
"value": "Every move is recorded in the target product\u0027s audit log as \"User moved device to : \". After upgrading, review the audit_logs rows with resource type NervesHub.Products.Product and a description containing \"moved device\" whose actor does not hold the manage or admin role in that organization, and move or remove the devices they brought in. Roles may have changed since a move, and entries older than the retention period are gone when TRUNCATE_AUDIT_LOGS_ENABLED is on."
}
],
"source": {
"discovery": "UNKNOWN"
},
"title": "Incorrect authorization in the devices page bulk move allows view-role members to move devices into another organization and receive its firmware",
"x_proofOfConcept": [
{
"lang": "en",
"supportingMedia": [
{
"base64": false,
"type": "text/html",
"value": "\u003cp\u003eAs a user with \u003ccode\u003emanage\u003c/code\u003e in organization A and \u003ccode\u003eview\u003c/code\u003e in organization B, where B has an active deployment group:\u003c/p\u003e\n\u003col\u003e\n\u003cli\u003eOn A\u0027s devices page, give a device you control the tags B\u0027s deployment group asks for. A \u003ccode\u003eview\u003c/code\u003e member can see the group\u0027s conditions.\u003c/li\u003e\n\u003cli\u003eSelect the device, choose B\u0027s product under \"Move device(s) to product\" and press Move. The page reports the move as successful, and the device is now in B\u0027s product.\u003c/li\u003e\n\u003cli\u003eConnect the device, reporting the platform and architecture of the group\u0027s firmware. It joins the group and is sent B\u0027s firmware with a download URL.\u003c/li\u003e\n\u003c/ol\u003e"
},
{
"base64": false,
"type": "text/markdown",
"value": "As a user with `manage` in organization A and `view` in organization B, where B has an active deployment group:\n\n1. On A\u0027s devices page, give a device you control the tags B\u0027s deployment group asks for. A `view` member can see the group\u0027s conditions.\n2. Select the device, choose B\u0027s product under \"Move device(s) to product\" and press Move. The page reports the move as successful, and the device is now in B\u0027s product.\n3. Connect the device, reporting the platform and architecture of the group\u0027s firmware. It joins the group and is sent B\u0027s firmware with a download URL."
}
],
"value": "As a user with manage in organization A and view in organization B, where B has an active deployment group:\n\n* On A\u0027s devices page, give a device you control the tags B\u0027s deployment group asks for. A view member can see the group\u0027s conditions.\n* Select the device, choose B\u0027s product under \"Move device(s) to product\" and press Move. The page reports the move as successful, and the device is now in B\u0027s product.\n* Connect the device, reporting the platform and architecture of the group\u0027s firmware. It joins the group and is sent B\u0027s firmware with a download URL."
}
],
"x_technicalAnalysis": [
{
"lang": "en",
"supportingMedia": [
{
"base64": false,
"type": "text/html",
"value": "\u003cp\u003e\u003cstrong\u003e1. Target selection.\u003c/strong\u003e The bulk actions sidebar (\u003ccode\u003eNervesHubWeb.Components.BulkActionsSidebar\u003c/code\u003e) lists the products of every organization in \u003ccode\u003ecurrent_scope.user.orgs\u003c/code\u003e. Choosing one sends \u003ccode\u003etarget-product\u003c/code\u003e to \u003ccode\u003eNervesHubWeb.Live.Devices.Index\u003c/code\u003e, which requires \u003ccode\u003edevice:update\u003c/code\u003e in the current organization and then calls \u003ccode\u003eNervesHub.Products.get_by_id/2\u003c/code\u003e. That query joins the product\u0027s organization to its \u003ccode\u003eorg_users\u003c/code\u003e rows and filters on the user\u0027s id only, so any membership in the target organization satisfies it, including the \u003ccode\u003eview\u003c/code\u003e role.\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003e2. The move.\u003c/strong\u003e \u003ccode\u003emove-devices-product\u003c/code\u003e passes the selected devices, or every device matching the current filter, to \u003ccode\u003eNervesHub.Devices.BulkActions.move_many/3\u003c/code\u003e, which calls \u003ccode\u003eDevices.move/3\u003c/code\u003e for each one and rewrites its \u003ccode\u003eorg_id\u003c/code\u003e and \u003ccode\u003eproduct_id\u003c/code\u003e. No check of the user\u0027s role in the target organization happens on this path, while the API\u0027s move in \u003ccode\u003eNervesHubWeb.API.DeviceController\u003c/code\u003e requires \u003ccode\u003emanage\u003c/code\u003e there.\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003e3. Effect.\u003c/strong\u003e The moved device still authenticates with its own certificate. On its next connection it is matched against the target product\u0027s deployment groups by platform, architecture and tags, joins one, and is sent that group\u0027s firmware with a download URL.\u003c/p\u003e"
},
{
"base64": false,
"type": "text/markdown",
"value": "**1. Target selection.** The bulk actions sidebar (`NervesHubWeb.Components.BulkActionsSidebar`) lists the products of every organization in `current_scope.user.orgs`. Choosing one sends `target-product` to `NervesHubWeb.Live.Devices.Index`, which requires `device:update` in the current organization and then calls `NervesHub.Products.get_by_id/2`. That query joins the product\u0027s organization to its `org_users` rows and filters on the user\u0027s id only, so any membership in the target organization satisfies it, including the `view` role.\n\n**2. The move.** `move-devices-product` passes the selected devices, or every device matching the current filter, to `NervesHub.Devices.BulkActions.move_many/3`, which calls `Devices.move/3` for each one and rewrites its `org_id` and `product_id`. No check of the user\u0027s role in the target organization happens on this path, while the API\u0027s move in `NervesHubWeb.API.DeviceController` requires `manage` there.\n\n**3. Effect.** The moved device still authenticates with its own certificate. On its next connection it is matched against the target product\u0027s deployment groups by platform, architecture and tags, joins one, and is sent that group\u0027s firmware with a download URL."
}
],
"value": "1. Target selection. The bulk actions sidebar (NervesHubWeb.Components.BulkActionsSidebar) lists the products of every organization in current_scope.user.orgs. Choosing one sends target-product to NervesHubWeb.Live.Devices.Index, which requires device:update in the current organization and then calls NervesHub.Products.get_by_id/2. That query joins the product\u0027s organization to its org_users rows and filters on the user\u0027s id only, so any membership in the target organization satisfies it, including the view role.\n\n2. The move. move-devices-product passes the selected devices, or every device matching the current filter, to NervesHub.Devices.BulkActions.move_many/3, which calls Devices.move/3 for each one and rewrites its org_id and product_id. No check of the user\u0027s role in the target organization happens on this path, while the API\u0027s move in NervesHubWeb.API.DeviceController requires manage there.\n\n3. Effect. The moved device still authenticates with its own certificate. On its next connection it is matched against the target product\u0027s deployment groups by platform, architecture and tags, joins one, and is sent that group\u0027s firmware with a download URL."
}
]
}
},
"cveMetadata": {
"assignerOrgId": "6b3ad84c-e1a6-4bf7-a703-f496b71e49db",
"assignerShortName": "EEF",
"cveId": "CVE-2026-120295",
"datePublished": "2026-11-10T10:43:26.342Z",
"dateReserved": "2026-11-06T09:58:06.998Z",
"dateUpdated": "2026-11-12T17:43:44.771Z",
"state": "PUBLISHED"
},
"dataType": "CVE_RECORD",
"dataVersion": "5.2"
}
CVE-2026-119613 (GCVE-0-2026-119613)
Vulnerability from cvelistv5 – Published: 2026-11-09 13:03 – Updated: 2026-11-12 16:11
VLAI
EPSS
VEX
Title
AshAi sends unrecognized tool errors verbatim to MCP clients and the model, disclosing internal details
Summary
Generation of Error Message Containing Sensitive Information vulnerability in ash-project ash_ai allows a caller of an AshAi tool to read internal error details, including module names and data values, by making a tool call fail.
AshAi.ToToolError gained an implementation for Ash.Error.Unknown.UnknownError that returns the error's error field as is, and AshAi.Tool.Errors.format/1 sends that text to MCP clients (tool calls and resource reads in AshAi.Mcp.Server) and to the model in AshAi.ToolLoop. Ash stores the full Exception.format/2 output of a raised exception, or inspect/1 of a bare {:error, term} return, in that field. A tool result that cannot be JSON-encoded raises a Protocol.UndefinedError whose text includes the inspected value, which can be a whole record with fields the tool never returns. None of these errors were logged. The fix logs them under an error id and returns only that id to the caller.
This issue affects ash_ai: from 1.5.0 before 1.5.1.
Severity
SSVC
Exploitation: poc
Automatable: no
Technical Impact: partial
CISA Coordinator · CISA-ADP (v2.0.3)
Decision recorded 2026-11-12 16:10 UTC
CWE
- CWE-209 - Generation of Error Message Containing Sensitive Information
Assigner
References
5 references
| URL | Tags |
|---|---|
| https://github.com/ash-project/ash_ai/security/ad… | vendor-advisoryrelated |
| https://cna.erlef.org/cves/CVE-2026-119613.html | related |
| https://osv.dev/vulnerability/EEF-CVE-2026-119613 | related |
| https://github.com/ash-project/ash_ai/commit/fbad… | related |
| https://github.com/ash-project/ash_ai/commit/af79… | patch |
Impacted products
2 products
| Vendor | Product | Version | |
|---|---|---|---|
| ash-project | ash_ai |
Affected:
1.5.0 , < 1.5.1
(semver)
cpe:2.3:a:ash-project:ash_ai:*:*:*:*:*:*:*:* |
|
| ash-project | ash_ai |
Affected:
fbad04e9620ef70603f4e67e03f15092f58578f6 , < af7987769d78ed070c8a9f6e6ee542d91744eae6
(git)
cpe:2.3:a:ash-project:ash_ai:*:*:*:*:*:*:*:* |
{
"containers": {
"adp": [
{
"metrics": [
{
"other": {
"content": {
"id": "CVE-2026-119613",
"options": [
{
"Exploitation": "poc"
},
{
"Automatable": "no"
},
{
"Technical Impact": "partial"
}
],
"role": "CISA Coordinator",
"timestamp": "2026-11-12T16:10:54.442769Z",
"version": "2.0.3"
},
"type": "ssvc"
}
}
],
"providerMetadata": {
"dateUpdated": "2026-11-12T16:11:19.623Z",
"orgId": "134c704f-9b21-4f2e-91b3-4a467353bcc0",
"shortName": "CISA-ADP"
},
"references": [
{
"tags": [
"exploit"
],
"url": "https://github.com/ash-project/ash_ai/security/advisories/GHSA-vhmw-fwrr-r6c6"
}
],
"title": "CISA ADP Vulnrichment"
}
],
"cna": {
"affected": [
{
"collectionURL": "https://repo.hex.pm",
"cpes": [
"cpe:2.3:a:ash-project:ash_ai:*:*:*:*:*:*:*:*"
],
"defaultStatus": "unaffected",
"modules": [
"\u0027Elixir.AshAi.ToToolError.Ash.Error.Unknown.UnknownError\u0027",
"\u0027Elixir.AshAi.Tool.Errors\u0027",
"\u0027Elixir.AshAi.Mcp.Router\u0027",
"\u0027Elixir.AshAi.ToolLoop\u0027"
],
"packageName": "ash_ai",
"packageURL": "pkg:hex/ash_ai",
"product": "ash_ai",
"programFiles": [
"lib/ash_ai/to_tool_error.ex",
"lib/ash_ai/tool/errors.ex",
"lib/ash_ai/mcp/router.ex",
"lib/ash_ai/tool_loop.ex"
],
"programRoutines": [
{
"name": "\u0027Elixir.AshAi.ToToolError.Ash.Error.Unknown.UnknownError\u0027:to_tool_error/1"
},
{
"name": "\u0027Elixir.AshAi.Tool.Errors\u0027:format/1"
},
{
"name": "\u0027Elixir.AshAi.Mcp.Router\u0027:call/2"
},
{
"name": "\u0027Elixir.AshAi.ToolLoop\u0027:run/2"
}
],
"repo": "https://github.com/ash-project/ash_ai",
"vendor": "ash-project",
"versions": [
{
"lessThan": "1.5.1",
"status": "affected",
"version": "1.5.0",
"versionType": "semver"
}
]
},
{
"collectionURL": "https://github.com",
"cpes": [
"cpe:2.3:a:ash-project:ash_ai:*:*:*:*:*:*:*:*"
],
"defaultStatus": "unaffected",
"modules": [
"\u0027Elixir.AshAi.ToToolError.Ash.Error.Unknown.UnknownError\u0027",
"\u0027Elixir.AshAi.Tool.Errors\u0027",
"\u0027Elixir.AshAi.Mcp.Router\u0027",
"\u0027Elixir.AshAi.ToolLoop\u0027"
],
"packageName": "ash-project/ash_ai",
"packageURL": "pkg:github/ash-project/ash_ai",
"product": "ash_ai",
"programFiles": [
"lib/ash_ai/to_tool_error.ex",
"lib/ash_ai/tool/errors.ex",
"lib/ash_ai/mcp/router.ex",
"lib/ash_ai/tool_loop.ex"
],
"programRoutines": [
{
"name": "\u0027Elixir.AshAi.ToToolError.Ash.Error.Unknown.UnknownError\u0027:to_tool_error/1"
},
{
"name": "\u0027Elixir.AshAi.Tool.Errors\u0027:format/1"
},
{
"name": "\u0027Elixir.AshAi.Mcp.Router\u0027:call/2"
},
{
"name": "\u0027Elixir.AshAi.ToolLoop\u0027:run/2"
}
],
"repo": "https://github.com/ash-project/ash_ai",
"vendor": "ash-project",
"versions": [
{
"lessThan": "af7987769d78ed070c8a9f6e6ee542d91744eae6",
"status": "affected",
"version": "fbad04e9620ef70603f4e67e03f15092f58578f6",
"versionType": "git"
}
]
}
],
"cpeApplicability": [
{
"nodes": [
{
"cpeMatch": [
{
"criteria": "cpe:2.3:a:ash-project:ash_ai:*:*:*:*:*:*:*:*",
"versionEndExcluding": "1.5.1",
"versionStartIncluding": "1.5.0",
"vulnerable": true
}
],
"negate": false,
"operator": "OR"
}
],
"operator": "AND"
}
],
"credits": [
{
"lang": "en",
"type": "finder",
"value": "Sebastian Seilund"
},
{
"lang": "en",
"type": "reporter",
"value": "Sebastian Seilund"
},
{
"lang": "en",
"type": "remediation developer",
"value": "Zach Daniel / Ash Project"
},
{
"lang": "en",
"type": "coordinator",
"value": "Jonatan M\u00e4nnchen / EEF"
}
],
"dateAssigned": "2026-11-07T21:44:36.000Z",
"descriptions": [
{
"lang": "en",
"supportingMedia": [
{
"base64": false,
"type": "text/html",
"value": "\u003cp\u003eGeneration of Error Message Containing Sensitive Information vulnerability in ash-project ash_ai allows a caller of an AshAi tool to read internal error details, including module names and data values, by making a tool call fail.\u003c/p\u003e\n\u003cp\u003e\u003ccode\u003eAshAi.ToToolError\u003c/code\u003e gained an implementation for \u003ccode\u003eAsh.Error.Unknown.UnknownError\u003c/code\u003e that returns the error\u0027s \u003ccode\u003eerror\u003c/code\u003e field as is, and \u003ccode\u003eAshAi.Tool.Errors.format/1\u003c/code\u003e sends that text to MCP clients (tool calls and resource reads in \u003ccode\u003eAshAi.Mcp.Server\u003c/code\u003e) and to the model in \u003ccode\u003eAshAi.ToolLoop\u003c/code\u003e. Ash stores the full \u003ccode\u003eException.format/2\u003c/code\u003e output of a raised exception, or \u003ccode\u003einspect/1\u003c/code\u003e of a bare \u003ccode\u003e{:error, term}\u003c/code\u003e return, in that field. A tool result that cannot be JSON-encoded raises a \u003ccode\u003eProtocol.UndefinedError\u003c/code\u003e whose text includes the inspected value, which can be a whole record with fields the tool never returns. None of these errors were logged. The fix logs them under an error id and returns only that id to the caller.\u003c/p\u003e\n\u003cp\u003eThis issue affects ash_ai: from 1.5.0 before 1.5.1.\u003c/p\u003e"
},
{
"base64": false,
"type": "text/markdown",
"value": "Generation of Error Message Containing Sensitive Information vulnerability in ash-project ash_ai allows a caller of an AshAi tool to read internal error details, including module names and data values, by making a tool call fail.\n\n`AshAi.ToToolError` gained an implementation for `Ash.Error.Unknown.UnknownError` that returns the error\u0027s `error` field as is, and `AshAi.Tool.Errors.format/1` sends that text to MCP clients (tool calls and resource reads in `AshAi.Mcp.Server`) and to the model in `AshAi.ToolLoop`. Ash stores the full `Exception.format/2` output of a raised exception, or `inspect/1` of a bare `{:error, term}` return, in that field. A tool result that cannot be JSON-encoded raises a `Protocol.UndefinedError` whose text includes the inspected value, which can be a whole record with fields the tool never returns. None of these errors were logged. The fix logs them under an error id and returns only that id to the caller.\n\nThis issue affects ash_ai: from 1.5.0 before 1.5.1."
}
],
"value": "Generation of Error Message Containing Sensitive Information vulnerability in ash-project ash_ai allows a caller of an AshAi tool to read internal error details, including module names and data values, by making a tool call fail.\n\nAshAi.ToToolError gained an implementation for Ash.Error.Unknown.UnknownError that returns the error\u0027s error field as is, and AshAi.Tool.Errors.format/1 sends that text to MCP clients (tool calls and resource reads in AshAi.Mcp.Server) and to the model in AshAi.ToolLoop. Ash stores the full Exception.format/2 output of a raised exception, or inspect/1 of a bare {:error, term} return, in that field. A tool result that cannot be JSON-encoded raises a Protocol.UndefinedError whose text includes the inspected value, which can be a whole record with fields the tool never returns. None of these errors were logged. The fix logs them under an error id and returns only that id to the caller.\n\nThis issue affects ash_ai: from 1.5.0 before 1.5.1."
}
],
"impacts": [
{
"capecId": "CAPEC-215",
"descriptions": [
{
"lang": "en",
"supportingMedia": [
{
"base64": false,
"type": "text/html",
"value": "\u003cp\u003eA caller with tool access sends unexpected or malformed tool arguments until a tool fails, then reads the returned error text to learn internal module and function names and the values the code failed on.\u003c/p\u003e"
},
{
"base64": false,
"type": "text/markdown",
"value": "A caller with tool access sends unexpected or malformed tool arguments until a tool fails, then reads the returned error text to learn internal module and function names and the values the code failed on."
}
],
"value": "A caller with tool access sends unexpected or malformed tool arguments until a tool fails, then reads the returned error text to learn internal module and function names and the values the code failed on."
}
]
}
],
"metrics": [
{
"cvssV4_0": {
"Automatable": "NOT_DEFINED",
"Recovery": "NOT_DEFINED",
"Safety": "NOT_DEFINED",
"attackComplexity": "LOW",
"attackRequirements": "NONE",
"attackVector": "NETWORK",
"baseScore": 5.3,
"baseSeverity": "MEDIUM",
"privilegesRequired": "LOW",
"providerUrgency": "NOT_DEFINED",
"subAvailabilityImpact": "NONE",
"subConfidentialityImpact": "NONE",
"subIntegrityImpact": "NONE",
"userInteraction": "NONE",
"valueDensity": "NOT_DEFINED",
"vectorString": "CVSS:4.0/AV:N/AC:L/AT:N/PR:L/UI:N/VC:L/VI:N/VA:N/SC:N/SI:N/SA:N",
"version": "4.0",
"vulnAvailabilityImpact": "NONE",
"vulnConfidentialityImpact": "LOW",
"vulnIntegrityImpact": "NONE",
"vulnerabilityResponseEffort": "NOT_DEFINED"
},
"format": "CVSS",
"scenarios": [
{
"lang": "en",
"value": "GENERAL"
}
]
}
],
"problemTypes": [
{
"descriptions": [
{
"cweId": "CWE-209",
"description": "CWE-209 Generation of Error Message Containing Sensitive Information",
"lang": "en",
"type": "CWE"
}
]
}
],
"providerMetadata": {
"dateUpdated": "2026-11-09T13:03:21.386Z",
"orgId": "6b3ad84c-e1a6-4bf7-a703-f496b71e49db",
"shortName": "EEF"
},
"references": [
{
"name": "Information disclosure in `AshAi.ToToolError` in AshAi sends raw exception text to tool callers",
"tags": [
"vendor-advisory",
"related"
],
"url": "https://github.com/ash-project/ash_ai/security/advisories/GHSA-vhmw-fwrr-r6c6"
},
{
"name": "EEF CNA record for CVE-2026-119613",
"tags": [
"related"
],
"url": "https://cna.erlef.org/cves/CVE-2026-119613.html"
},
{
"name": "OSV record EEF-CVE-2026-119613",
"tags": [
"related"
],
"url": "https://osv.dev/vulnerability/EEF-CVE-2026-119613"
},
{
"name": "Introducing commit fbad04e in ash-project/ash_ai",
"tags": [
"related"
],
"url": "https://github.com/ash-project/ash_ai/commit/fbad04e9620ef70603f4e67e03f15092f58578f6"
},
{
"name": "Fix commit af79877 in ash-project/ash_ai",
"tags": [
"patch"
],
"url": "https://github.com/ash-project/ash_ai/commit/af7987769d78ed070c8a9f6e6ee542d91744eae6"
}
],
"source": {
"discovery": "EXTERNAL"
},
"title": "AshAi sends unrecognized tool errors verbatim to MCP clients and the model, disclosing internal details"
}
},
"cveMetadata": {
"assignerOrgId": "6b3ad84c-e1a6-4bf7-a703-f496b71e49db",
"assignerShortName": "EEF",
"cveId": "CVE-2026-119613",
"datePublished": "2026-11-09T13:03:21.386Z",
"dateReserved": "2026-10-30T23:45:01.997Z",
"dateUpdated": "2026-11-12T16:11:19.623Z",
"state": "PUBLISHED"
},
"dataType": "CVE_RECORD",
"dataVersion": "5.2"
}
CVE-2026-71397 (GCVE-0-2026-71397)
Vulnerability from cvelistv5 – Published: 2026-11-06 21:02 – Updated: 2026-11-10 14:17
VLAI
EPSS
VEX
Title
Unbounded request body accumulation on streaming RPCs causes memory exhaustion in grpc_server
Summary
Allocation of Resources Without Limits or Throttling vulnerability in elixir-grpc grpc_server allows unauthenticated attackers to exhaust memory and crash the node via an oversized client-streaming or bidirectional-streaming request body.
The streaming read clause of 'Elixir.GRPC.Server.Adapters.Cowboy.Handler':info/3 (grpc_server/lib/grpc/server/adapters/cowboy/handler.ex) never checks the configured max_body_size. Only the unary read path enforces that cap. 'Elixir.GRPC.Server.Adapters.Cowboy':reading_stream/1 (grpc_server/lib/grpc/server/adapters/cowboy.ex) appends every received chunk to its buffer until a complete length-prefixed message arrives. A client can declare a very large message and send it across many small HTTP/2 DATA frames, and the server buffers all of it.
This issue affects grpc_server: from 1.0.0 before 1.0.6.
Severity
SSVC
Exploitation: poc
Automatable: no
Technical Impact: partial
CISA Coordinator · CISA-ADP (v2.0.3)
Decision recorded 2026-11-10 14:16 UTC
CWE
- CWE-770 - Allocation of Resources Without Limits or Throttling
Assigner
References
5 references
| URL | Tags |
|---|---|
| https://github.com/elixir-grpc/grpc/security/advi… | vendor-advisoryrelated |
| https://cna.erlef.org/cves/CVE-2026-71397.html | related |
| https://osv.dev/vulnerability/EEF-CVE-2026-71397 | related |
| https://github.com/elixir-grpc/grpc/commit/49e18c… | related |
| https://github.com/elixir-grpc/grpc/commit/0b663c… | patch |
Impacted products
2 products
| Vendor | Product | Version | |
|---|---|---|---|
| elixir-grpc | grpc_server |
Affected:
1.0.0 , < 1.0.6
(semver)
cpe:2.3:a:elixir-grpc:grpc_server:*:*:*:*:*:*:*:* |
|
| elixir-grpc | grpc_server |
Affected:
49e18c3ec6bb9afe2f712caad3dbab5c56a68a00 , < 0b663ca53dbf0f36f7f67ff1d0026f4482a18915
(git)
cpe:2.3:a:elixir-grpc:grpc_server:*:*:*:*:*:*:*:* |
{
"containers": {
"adp": [
{
"metrics": [
{
"other": {
"content": {
"id": "CVE-2026-71397",
"options": [
{
"Exploitation": "poc"
},
{
"Automatable": "no"
},
{
"Technical Impact": "partial"
}
],
"role": "CISA Coordinator",
"timestamp": "2026-11-10T14:16:42.568773Z",
"version": "2.0.3"
},
"type": "ssvc"
}
}
],
"providerMetadata": {
"dateUpdated": "2026-11-10T14:17:05.630Z",
"orgId": "134c704f-9b21-4f2e-91b3-4a467353bcc0",
"shortName": "CISA-ADP"
},
"references": [
{
"tags": [
"exploit"
],
"url": "https://github.com/elixir-grpc/grpc/security/advisories/GHSA-vx6q-cr5q-945g"
}
],
"title": "CISA ADP Vulnrichment"
}
],
"cna": {
"affected": [
{
"collectionURL": "https://repo.hex.pm",
"cpes": [
"cpe:2.3:a:elixir-grpc:grpc_server:*:*:*:*:*:*:*:*"
],
"defaultStatus": "unaffected",
"modules": [
"\u0027Elixir.GRPC.Server.Adapters.Cowboy.Handler\u0027",
"\u0027Elixir.GRPC.Server.Adapters.Cowboy\u0027"
],
"packageName": "grpc_server",
"packageURL": "pkg:hex/grpc_server",
"product": "grpc_server",
"programFiles": [
"lib/grpc/server/adapters/cowboy/handler.ex",
"lib/grpc/server/adapters/cowboy.ex"
],
"programRoutines": [
{
"name": "\u0027Elixir.GRPC.Server.Adapters.Cowboy.Handler\u0027:info/3"
},
{
"name": "\u0027Elixir.GRPC.Server.Adapters.Cowboy\u0027:reading_stream/1"
}
],
"repo": "https://github.com/elixir-grpc/grpc",
"vendor": "elixir-grpc",
"versions": [
{
"lessThan": "1.0.6",
"status": "affected",
"version": "1.0.0",
"versionType": "semver"
}
]
},
{
"collectionURL": "https://github.com",
"cpes": [
"cpe:2.3:a:elixir-grpc:grpc_server:*:*:*:*:*:*:*:*"
],
"defaultStatus": "unaffected",
"modules": [
"\u0027Elixir.GRPC.Server.Adapters.Cowboy.Handler\u0027",
"\u0027Elixir.GRPC.Server.Adapters.Cowboy\u0027"
],
"packageName": "elixir-grpc/grpc",
"packageURL": "pkg:github/elixir-grpc/grpc",
"product": "grpc_server",
"programFiles": [
"grpc_server/lib/grpc/server/adapters/cowboy/handler.ex",
"grpc_server/lib/grpc/server/adapters/cowboy.ex"
],
"programRoutines": [
{
"name": "\u0027Elixir.GRPC.Server.Adapters.Cowboy.Handler\u0027:info/3"
},
{
"name": "\u0027Elixir.GRPC.Server.Adapters.Cowboy\u0027:reading_stream/1"
}
],
"repo": "https://github.com/elixir-grpc/grpc",
"vendor": "elixir-grpc",
"versions": [
{
"lessThan": "0b663ca53dbf0f36f7f67ff1d0026f4482a18915",
"status": "affected",
"version": "49e18c3ec6bb9afe2f712caad3dbab5c56a68a00",
"versionType": "git"
}
]
}
],
"configurations": [
{
"lang": "en",
"supportingMedia": [
{
"base64": false,
"type": "text/html",
"value": "\u003cp\u003eThe server must expose at least one client-streaming or bidirectional-streaming RPC method.\u003c/p\u003e"
},
{
"base64": false,
"type": "text/markdown",
"value": "The server must expose at least one client-streaming or bidirectional-streaming RPC method."
}
],
"value": "The server must expose at least one client-streaming or bidirectional-streaming RPC method."
}
],
"cpeApplicability": [
{
"nodes": [
{
"cpeMatch": [
{
"criteria": "cpe:2.3:a:elixir-grpc:grpc_server:*:*:*:*:*:*:*:*",
"versionEndExcluding": "1.0.6",
"versionStartIncluding": "1.0.0",
"vulnerable": true
}
],
"negate": false,
"operator": "OR"
}
],
"operator": "AND"
}
],
"credits": [
{
"lang": "en",
"type": "finder",
"value": "Arpit Jain"
},
{
"lang": "en",
"type": "reporter",
"value": "Arpit Jain"
},
{
"lang": "en",
"type": "remediation developer",
"value": "Arpit Jain"
},
{
"lang": "en",
"type": "remediation reviewer",
"value": "Paulo Valente"
},
{
"lang": "en",
"type": "coordinator",
"value": "Jonatan M\u00e4nnchen / EEF"
}
],
"descriptions": [
{
"lang": "en",
"supportingMedia": [
{
"base64": false,
"type": "text/html",
"value": "\u003cp\u003eAllocation of Resources Without Limits or Throttling vulnerability in elixir-grpc grpc_server allows unauthenticated attackers to exhaust memory and crash the node via an oversized client-streaming or bidirectional-streaming request body.\u003c/p\u003e\n\u003cp\u003eThe streaming read clause of \u003ccode\u003e\u0027Elixir.GRPC.Server.Adapters.Cowboy.Handler\u0027:info/3\u003c/code\u003e (\u003ccode\u003egrpc_server/lib/grpc/server/adapters/cowboy/handler.ex\u003c/code\u003e) never checks the configured \u003ccode\u003emax_body_size\u003c/code\u003e. Only the unary read path enforces that cap. \u003ccode\u003e\u0027Elixir.GRPC.Server.Adapters.Cowboy\u0027:reading_stream/1\u003c/code\u003e (\u003ccode\u003egrpc_server/lib/grpc/server/adapters/cowboy.ex\u003c/code\u003e) appends every received chunk to its buffer until a complete length-prefixed message arrives. A client can declare a very large message and send it across many small HTTP/2 DATA frames, and the server buffers all of it.\u003c/p\u003e\n\u003cp\u003eThis issue affects grpc_server: from 1.0.0 before 1.0.6.\u003c/p\u003e"
},
{
"base64": false,
"type": "text/markdown",
"value": "Allocation of Resources Without Limits or Throttling vulnerability in elixir-grpc grpc_server allows unauthenticated attackers to exhaust memory and crash the node via an oversized client-streaming or bidirectional-streaming request body.\n\nThe streaming read clause of `\u0027Elixir.GRPC.Server.Adapters.Cowboy.Handler\u0027:info/3` (`grpc_server/lib/grpc/server/adapters/cowboy/handler.ex`) never checks the configured `max_body_size`. Only the unary read path enforces that cap. `\u0027Elixir.GRPC.Server.Adapters.Cowboy\u0027:reading_stream/1` (`grpc_server/lib/grpc/server/adapters/cowboy.ex`) appends every received chunk to its buffer until a complete length-prefixed message arrives. A client can declare a very large message and send it across many small HTTP/2 DATA frames, and the server buffers all of it.\n\nThis issue affects grpc_server: from 1.0.0 before 1.0.6."
}
],
"value": "Allocation of Resources Without Limits or Throttling vulnerability in elixir-grpc grpc_server allows unauthenticated attackers to exhaust memory and crash the node via an oversized client-streaming or bidirectional-streaming request body.\n\nThe streaming read clause of \u0027Elixir.GRPC.Server.Adapters.Cowboy.Handler\u0027:info/3 (grpc_server/lib/grpc/server/adapters/cowboy/handler.ex) never checks the configured max_body_size. Only the unary read path enforces that cap. \u0027Elixir.GRPC.Server.Adapters.Cowboy\u0027:reading_stream/1 (grpc_server/lib/grpc/server/adapters/cowboy.ex) appends every received chunk to its buffer until a complete length-prefixed message arrives. A client can declare a very large message and send it across many small HTTP/2 DATA frames, and the server buffers all of it.\n\nThis issue affects grpc_server: from 1.0.0 before 1.0.6."
}
],
"impacts": [
{
"capecId": "CAPEC-130",
"descriptions": [
{
"lang": "en",
"supportingMedia": [
{
"base64": false,
"type": "text/html",
"value": "\u003cp\u003eAn unauthenticated client can crash a \u003ccode\u003egrpc_server\u003c/code\u003e node through one streaming RPC connection. This takes down every service on that node.\u003c/p\u003e"
},
{
"base64": false,
"type": "text/markdown",
"value": "An unauthenticated client can crash a `grpc_server` node through one streaming RPC connection. This takes down every service on that node."
}
],
"value": "An unauthenticated client can crash a grpc_server node through one streaming RPC connection. This takes down every service on that node."
}
]
}
],
"metrics": [
{
"cvssV4_0": {
"Automatable": "NOT_DEFINED",
"Recovery": "NOT_DEFINED",
"Safety": "NOT_DEFINED",
"attackComplexity": "LOW",
"attackRequirements": "PRESENT",
"attackVector": "NETWORK",
"baseScore": 8.2,
"baseSeverity": "HIGH",
"privilegesRequired": "NONE",
"providerUrgency": "NOT_DEFINED",
"subAvailabilityImpact": "NONE",
"subConfidentialityImpact": "NONE",
"subIntegrityImpact": "NONE",
"userInteraction": "NONE",
"valueDensity": "NOT_DEFINED",
"vectorString": "CVSS:4.0/AV:N/AC:L/AT:P/PR:N/UI:N/VC:N/VI:N/VA:H/SC:N/SI:N/SA:N",
"version": "4.0",
"vulnAvailabilityImpact": "HIGH",
"vulnConfidentialityImpact": "NONE",
"vulnIntegrityImpact": "NONE",
"vulnerabilityResponseEffort": "NOT_DEFINED"
},
"format": "CVSS",
"scenarios": [
{
"lang": "en",
"value": "GENERAL"
}
]
}
],
"problemTypes": [
{
"descriptions": [
{
"cweId": "CWE-770",
"description": "CWE-770 Allocation of Resources Without Limits or Throttling",
"lang": "en",
"type": "CWE"
}
]
}
],
"providerMetadata": {
"dateUpdated": "2026-11-06T21:02:03.676Z",
"orgId": "6b3ad84c-e1a6-4bf7-a703-f496b71e49db",
"shortName": "EEF"
},
"references": [
{
"name": "Unbounded request body accumulation on streaming RPCs causes memory exhaustion in grpc_server",
"tags": [
"vendor-advisory",
"related"
],
"url": "https://github.com/elixir-grpc/grpc/security/advisories/GHSA-vx6q-cr5q-945g"
},
{
"name": "EEF CNA record for CVE-2026-71397",
"tags": [
"related"
],
"url": "https://cna.erlef.org/cves/CVE-2026-71397.html"
},
{
"name": "OSV record EEF-CVE-2026-71397",
"tags": [
"related"
],
"url": "https://osv.dev/vulnerability/EEF-CVE-2026-71397"
},
{
"name": "Introducing commit 49e18c3 in elixir-grpc/grpc",
"tags": [
"related"
],
"url": "https://github.com/elixir-grpc/grpc/commit/49e18c3ec6bb9afe2f712caad3dbab5c56a68a00"
},
{
"name": "Fix commit 0b663ca in elixir-grpc/grpc",
"tags": [
"patch"
],
"url": "https://github.com/elixir-grpc/grpc/commit/0b663ca53dbf0f36f7f67ff1d0026f4482a18915"
}
],
"source": {
"discovery": "EXTERNAL"
},
"title": "Unbounded request body accumulation on streaming RPCs causes memory exhaustion in grpc_server",
"x_proofOfConcept": [
{
"lang": "en",
"supportingMedia": [
{
"base64": false,
"type": "text/html",
"value": "\u003col\u003e\n\u003cli\u003eStart a \u003ccode\u003egrpc_server\u003c/code\u003e endpoint that exposes a client-streaming or bidirectional-streaming RPC method.\u003c/li\u003e\n\u003cli\u003eOpen an HTTP/2 connection and send a request to that method.\u003c/li\u003e\n\u003cli\u003eSend the request body across many small HTTP/2 DATA frames without \u003ccode\u003eEND_STREAM\u003c/code\u003e, well past the configured \u003ccode\u003emax_body_size\u003c/code\u003e.\u003c/li\u003e\n\u003cli\u003eObserve that the server buffers all of it and server memory grows. The equivalent unary call is rejected with \u003ccode\u003eRESOURCE_EXHAUSTED\u003c/code\u003e.\u003c/li\u003e\n\u003c/ol\u003e"
},
{
"base64": false,
"type": "text/markdown",
"value": "1. Start a `grpc_server` endpoint that exposes a client-streaming or bidirectional-streaming RPC method.\n2. Open an HTTP/2 connection and send a request to that method.\n3. Send the request body across many small HTTP/2 DATA frames without `END_STREAM`, well past the configured `max_body_size`.\n4. Observe that the server buffers all of it and server memory grows. The equivalent unary call is rejected with `RESOURCE_EXHAUSTED`."
}
],
"value": "* Start a grpc_server endpoint that exposes a client-streaming or bidirectional-streaming RPC method.\n* Open an HTTP/2 connection and send a request to that method.\n* Send the request body across many small HTTP/2 DATA frames without END_STREAM, well past the configured max_body_size.\n* Observe that the server buffers all of it and server memory grows. The equivalent unary call is rejected with RESOURCE_EXHAUSTED."
}
],
"x_technicalAnalysis": [
{
"lang": "en",
"supportingMedia": [
{
"base64": false,
"type": "text/html",
"value": "\u003cp\u003e\u003cstrong\u003e1. The cap applies to unary reads only\u003c/strong\u003e\u003c/p\u003e\n\u003cp\u003eThe commit that added \u003ccode\u003emax_body_size\u003c/code\u003e checks it in \u003ccode\u003eread_full_body/4\u003c/code\u003e, which serves unary requests through the \u003ccode\u003e{:read_full_body, ref, pid}\u003c/code\u003e clause of \u003ccode\u003e\u0027Elixir.GRPC.Server.Adapters.Cowboy.Handler\u0027:info/3\u003c/code\u003e. The \u003ccode\u003e{:read_body, ref, pid}\u003c/code\u003e clause, which serves streaming requests, returns each chunk to the caller with no size check.\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003e2. Unbounded buffering\u003c/strong\u003e\u003c/p\u003e\n\u003cp\u003e\u003ccode\u003e\u0027Elixir.GRPC.Server.Adapters.Cowboy\u0027:reading_stream/1\u003c/code\u003e reads chunks through that clause and appends each one to a binary buffer. It emits a message only when \u003ccode\u003eGRPC.Message.get_message/1\u003c/code\u003e finds a complete length-prefixed gRPC message in the buffer. Until then, the buffer grows with every chunk.\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003e3. Memory exhaustion\u003c/strong\u003e\u003c/p\u003e\n\u003cp\u003eA client that declares a large message length and keeps sending DATA frames without \u003ccode\u003eEND_STREAM\u003c/code\u003e makes the buffer grow without limit. The BEAM eventually runs out of memory and the node crashes.\u003c/p\u003e"
},
{
"base64": false,
"type": "text/markdown",
"value": "**1. The cap applies to unary reads only**\n\nThe commit that added `max_body_size` checks it in `read_full_body/4`, which serves unary requests through the `{:read_full_body, ref, pid}` clause of `\u0027Elixir.GRPC.Server.Adapters.Cowboy.Handler\u0027:info/3`. The `{:read_body, ref, pid}` clause, which serves streaming requests, returns each chunk to the caller with no size check.\n\n**2. Unbounded buffering**\n\n`\u0027Elixir.GRPC.Server.Adapters.Cowboy\u0027:reading_stream/1` reads chunks through that clause and appends each one to a binary buffer. It emits a message only when `GRPC.Message.get_message/1` finds a complete length-prefixed gRPC message in the buffer. Until then, the buffer grows with every chunk.\n\n**3. Memory exhaustion**\n\nA client that declares a large message length and keeps sending DATA frames without `END_STREAM` makes the buffer grow without limit. The BEAM eventually runs out of memory and the node crashes."
}
],
"value": "1. The cap applies to unary reads only\n\nThe commit that added max_body_size checks it in read_full_body/4, which serves unary requests through the {:read_full_body, ref, pid} clause of \u0027Elixir.GRPC.Server.Adapters.Cowboy.Handler\u0027:info/3. The {:read_body, ref, pid} clause, which serves streaming requests, returns each chunk to the caller with no size check.\n\n2. Unbounded buffering\n\n\u0027Elixir.GRPC.Server.Adapters.Cowboy\u0027:reading_stream/1 reads chunks through that clause and appends each one to a binary buffer. It emits a message only when GRPC.Message.get_message/1 finds a complete length-prefixed gRPC message in the buffer. Until then, the buffer grows with every chunk.\n\n3. Memory exhaustion\n\nA client that declares a large message length and keeps sending DATA frames without END_STREAM makes the buffer grow without limit. The BEAM eventually runs out of memory and the node crashes."
}
]
}
},
"cveMetadata": {
"assignerOrgId": "6b3ad84c-e1a6-4bf7-a703-f496b71e49db",
"assignerShortName": "EEF",
"cveId": "CVE-2026-71397",
"datePublished": "2026-11-06T21:02:03.676Z",
"dateReserved": "2026-08-19T10:30:01.929Z",
"dateUpdated": "2026-11-10T14:17:05.630Z",
"state": "PUBLISHED"
},
"dataType": "CVE_RECORD",
"dataVersion": "5.2"
}
CVE-2026-115701 (GCVE-0-2026-115701)
Vulnerability from cvelistv5 – Published: 2026-11-06 10:29 – Updated: 2026-11-10 15:02
VLAI
EPSS
VEX
Title
Cowboy HTTP/1.1 Unbounded Buffering of Pipelined Data During Request Handling Enables Memory Exhaustion
Summary
Allocation of Resources Without Limits or Throttling vulnerability in ninenines cowboy allows an unauthenticated remote attacker to exhaust connection process memory over HTTP/1.1.
Once a request has been received in full and its handler is still running, cowboy_http:parse/2 in src/cowboy_http.erl appends every further byte that arrives on the keep-alive connection to the connection buffer and returns to the receive loop without parsing it. The socket stays in active mode and no limit applies to this buffer: max_request_line_length, max_headers and the other parser limits only take effect once the handler returns and parsing resumes, and the only timer in this state is idle_timeout (default 60 seconds), which is reset on every received chunk. A client can therefore send an arbitrary amount of data on one connection for as long as the handler runs, and a few connections to an endpoint that holds requests open can drive the Erlang VM into out-of-memory conditions.
This issue affects cowboy: from 2.18.0 before 2.21.0.
Severity
SSVC
Exploitation: none
Automatable: no
Technical Impact: partial
CISA Coordinator · CISA-ADP (v2.0.3)
Decision recorded 2026-11-10 15:01 UTC
CWE
- CWE-770 - Allocation of Resources Without Limits or Throttling
Assigner
References
4 references
| URL | Tags |
|---|---|
| https://cna.erlef.org/cves/CVE-2026-115701.html | relatedthird-party-advisory |
| https://osv.dev/vulnerability/EEF-CVE-2026-115701 | related |
| https://github.com/ninenines/cowboy/commit/faf559… | related |
| https://github.com/ninenines/cowboy/commit/3a630c… | patch |
Impacted products
Date Public
2026-11-06 10:29
{
"containers": {
"adp": [
{
"metrics": [
{
"other": {
"content": {
"id": "CVE-2026-115701",
"options": [
{
"Exploitation": "none"
},
{
"Automatable": "no"
},
{
"Technical Impact": "partial"
}
],
"role": "CISA Coordinator",
"timestamp": "2026-11-10T15:01:56.291118Z",
"version": "2.0.3"
},
"type": "ssvc"
}
}
],
"providerMetadata": {
"dateUpdated": "2026-11-10T15:02:23.747Z",
"orgId": "134c704f-9b21-4f2e-91b3-4a467353bcc0",
"shortName": "CISA-ADP"
},
"title": "CISA ADP Vulnrichment"
}
],
"cna": {
"affected": [
{
"collectionURL": "https://repo.hex.pm",
"cpes": [
"cpe:2.3:a:ninenines:cowboy:*:*:*:*:*:*:*:*"
],
"defaultStatus": "unaffected",
"modules": [
"cowboy_http"
],
"packageName": "cowboy",
"packageURL": "pkg:hex/cowboy",
"product": "cowboy",
"programFiles": [
"src/cowboy_http.erl"
],
"programRoutines": [
{
"name": "cowboy_http:parse/2"
},
{
"name": "cowboy_http:loop/1"
}
],
"repo": "https://github.com/ninenines/cowboy",
"vendor": "ninenines",
"versions": [
{
"lessThan": "2.21.0",
"status": "affected",
"version": "2.18.0",
"versionType": "semver"
}
]
},
{
"collectionURL": "https://github.com",
"cpes": [
"cpe:2.3:a:ninenines:cowboy:*:*:*:*:*:*:*:*"
],
"defaultStatus": "unaffected",
"modules": [
"cowboy_http"
],
"packageName": "ninenines/cowboy",
"packageURL": "pkg:github/ninenines/cowboy",
"product": "cowboy",
"programFiles": [
"src/cowboy_http.erl"
],
"programRoutines": [
{
"name": "cowboy_http:parse/2"
},
{
"name": "cowboy_http:loop/1"
}
],
"repo": "https://github.com/ninenines/cowboy",
"vendor": "ninenines",
"versions": [
{
"lessThan": "3a630c07e1521abf63a1b9ea080509aa1337b60d",
"status": "affected",
"version": "faf5594fbf74530ace90b0c46c936bdc2cf82081",
"versionType": "git"
}
]
}
],
"configurations": [
{
"lang": "en",
"supportingMedia": [
{
"base64": false,
"type": "text/html",
"value": "\u003cp\u003eThe amount of data buffered scales with how long the current handler keeps the connection busy. Any HTTP/1.1 endpoint exposes a window, but deployments with handlers that hold a request open, such as long polling with \u003ccode\u003ecowboy_loop\u003c/code\u003e, streamed responses via \u003ccode\u003ecowboy_req:stream_reply/3\u003c/code\u003e, or slow backend calls, give an attacker the time to buffer significant memory. HTTP/2 connections are not affected.\u003c/p\u003e"
},
{
"base64": false,
"type": "text/markdown",
"value": "The amount of data buffered scales with how long the current handler keeps the connection busy. Any HTTP/1.1 endpoint exposes a window, but deployments with handlers that hold a request open, such as long polling with `cowboy_loop`, streamed responses via `cowboy_req:stream_reply/3`, or slow backend calls, give an attacker the time to buffer significant memory. HTTP/2 connections are not affected."
}
],
"value": "The amount of data buffered scales with how long the current handler keeps the connection busy. Any HTTP/1.1 endpoint exposes a window, but deployments with handlers that hold a request open, such as long polling with cowboy_loop, streamed responses via cowboy_req:stream_reply/3, or slow backend calls, give an attacker the time to buffer significant memory. HTTP/2 connections are not affected."
}
],
"cpeApplicability": [
{
"nodes": [
{
"cpeMatch": [
{
"criteria": "cpe:2.3:a:ninenines:cowboy:*:*:*:*:*:*:*:*",
"versionEndExcluding": "2.21.0",
"versionStartIncluding": "2.18.0",
"vulnerable": true
}
],
"negate": false,
"operator": "OR"
}
],
"operator": "AND"
}
],
"credits": [
{
"lang": "en",
"type": "finder",
"value": "Mikica Ivosevic"
},
{
"lang": "en",
"type": "reporter",
"value": "Mikica Ivosevic"
},
{
"lang": "en",
"type": "coordinator",
"value": "Jonatan M\u00e4nnchen / EEF"
}
],
"dateAssigned": "2026-10-26T10:41:07.000Z",
"datePublic": "2026-11-06T10:29:00.000Z",
"descriptions": [
{
"lang": "en",
"supportingMedia": [
{
"base64": false,
"type": "text/html",
"value": "\u003cp\u003eAllocation of Resources Without Limits or Throttling vulnerability in ninenines cowboy allows an unauthenticated remote attacker to exhaust connection process memory over HTTP/1.1.\u003c/p\u003e\n\u003cp\u003eOnce a request has been received in full and its handler is still running, \u003ccode\u003ecowboy_http:parse/2\u003c/code\u003e in \u003ccode\u003esrc/cowboy_http.erl\u003c/code\u003e appends every further byte that arrives on the keep-alive connection to the connection buffer and returns to the receive loop without parsing it. The socket stays in active mode and no limit applies to this buffer: \u003ccode\u003emax_request_line_length\u003c/code\u003e, \u003ccode\u003emax_headers\u003c/code\u003e and the other parser limits only take effect once the handler returns and parsing resumes, and the only timer in this state is \u003ccode\u003eidle_timeout\u003c/code\u003e (default 60 seconds), which is reset on every received chunk. A client can therefore send an arbitrary amount of data on one connection for as long as the handler runs, and a few connections to an endpoint that holds requests open can drive the Erlang VM into out-of-memory conditions.\u003c/p\u003e\n\u003cp\u003eThis issue affects cowboy: from 2.18.0 before 2.21.0.\u003c/p\u003e"
},
{
"base64": false,
"type": "text/markdown",
"value": "Allocation of Resources Without Limits or Throttling vulnerability in ninenines cowboy allows an unauthenticated remote attacker to exhaust connection process memory over HTTP/1.1.\n\nOnce a request has been received in full and its handler is still running, `cowboy_http:parse/2` in `src/cowboy_http.erl` appends every further byte that arrives on the keep-alive connection to the connection buffer and returns to the receive loop without parsing it. The socket stays in active mode and no limit applies to this buffer: `max_request_line_length`, `max_headers` and the other parser limits only take effect once the handler returns and parsing resumes, and the only timer in this state is `idle_timeout` (default 60 seconds), which is reset on every received chunk. A client can therefore send an arbitrary amount of data on one connection for as long as the handler runs, and a few connections to an endpoint that holds requests open can drive the Erlang VM into out-of-memory conditions.\n\nThis issue affects cowboy: from 2.18.0 before 2.21.0."
}
],
"value": "Allocation of Resources Without Limits or Throttling vulnerability in ninenines cowboy allows an unauthenticated remote attacker to exhaust connection process memory over HTTP/1.1.\n\nOnce a request has been received in full and its handler is still running, cowboy_http:parse/2 in src/cowboy_http.erl appends every further byte that arrives on the keep-alive connection to the connection buffer and returns to the receive loop without parsing it. The socket stays in active mode and no limit applies to this buffer: max_request_line_length, max_headers and the other parser limits only take effect once the handler returns and parsing resumes, and the only timer in this state is idle_timeout (default 60 seconds), which is reset on every received chunk. A client can therefore send an arbitrary amount of data on one connection for as long as the handler runs, and a few connections to an endpoint that holds requests open can drive the Erlang VM into out-of-memory conditions.\n\nThis issue affects cowboy: from 2.18.0 before 2.21.0."
}
],
"impacts": [
{
"capecId": "CAPEC-130",
"descriptions": [
{
"lang": "en",
"supportingMedia": [
{
"base64": false,
"type": "text/html",
"value": "\u003cp\u003eAn unauthenticated remote client can make one connection process hold an arbitrary amount of memory by sending data after a request while its handler runs. A few such connections to an endpoint that holds requests open exhaust node memory and terminate every connection served by that node.\u003c/p\u003e"
},
{
"base64": false,
"type": "text/markdown",
"value": "An unauthenticated remote client can make one connection process hold an arbitrary amount of memory by sending data after a request while its handler runs. A few such connections to an endpoint that holds requests open exhaust node memory and terminate every connection served by that node."
}
],
"value": "An unauthenticated remote client can make one connection process hold an arbitrary amount of memory by sending data after a request while its handler runs. A few such connections to an endpoint that holds requests open exhaust node memory and terminate every connection served by that node."
}
]
}
],
"metrics": [
{
"cvssV4_0": {
"Automatable": "NOT_DEFINED",
"Recovery": "NOT_DEFINED",
"Safety": "NOT_DEFINED",
"attackComplexity": "LOW",
"attackRequirements": "PRESENT",
"attackVector": "NETWORK",
"baseScore": 8.2,
"baseSeverity": "HIGH",
"privilegesRequired": "NONE",
"providerUrgency": "NOT_DEFINED",
"subAvailabilityImpact": "NONE",
"subConfidentialityImpact": "NONE",
"subIntegrityImpact": "NONE",
"userInteraction": "NONE",
"valueDensity": "NOT_DEFINED",
"vectorString": "CVSS:4.0/AV:N/AC:L/AT:P/PR:N/UI:N/VC:N/VI:N/VA:H/SC:N/SI:N/SA:N",
"version": "4.0",
"vulnAvailabilityImpact": "HIGH",
"vulnConfidentialityImpact": "NONE",
"vulnIntegrityImpact": "NONE",
"vulnerabilityResponseEffort": "NOT_DEFINED"
},
"format": "CVSS",
"scenarios": [
{
"lang": "en",
"value": "GENERAL"
}
]
}
],
"problemTypes": [
{
"descriptions": [
{
"cweId": "CWE-770",
"description": "CWE-770 Allocation of Resources Without Limits or Throttling",
"lang": "en",
"type": "CWE"
}
]
}
],
"providerMetadata": {
"dateUpdated": "2026-11-09T15:28:28.965Z",
"orgId": "6b3ad84c-e1a6-4bf7-a703-f496b71e49db",
"shortName": "EEF"
},
"references": [
{
"name": "EEF CNA record for CVE-2026-115701",
"tags": [
"related",
"third-party-advisory"
],
"url": "https://cna.erlef.org/cves/CVE-2026-115701.html"
},
{
"name": "OSV record EEF-CVE-2026-115701",
"tags": [
"related"
],
"url": "https://osv.dev/vulnerability/EEF-CVE-2026-115701"
},
{
"name": "Introducing commit faf5594 in ninenines/cowboy",
"tags": [
"related"
],
"url": "https://github.com/ninenines/cowboy/commit/faf5594fbf74530ace90b0c46c936bdc2cf82081"
},
{
"name": "Fix commit 3a630c0 in ninenines/cowboy",
"tags": [
"patch"
],
"url": "https://github.com/ninenines/cowboy/commit/3a630c07e1521abf63a1b9ea080509aa1337b60d"
}
],
"source": {
"discovery": "EXTERNAL"
},
"title": "Cowboy HTTP/1.1 Unbounded Buffering of Pipelined Data During Request Handling Enables Memory Exhaustion",
"x_proofOfConcept": [
{
"lang": "en",
"supportingMedia": [
{
"base64": false,
"type": "text/html",
"value": "\u003col\u003e\n\u003cli\u003eStart a cowboy HTTP/1.1 listener with a handler that takes time to reply, for example one that sleeps 20 seconds before calling \u003ccode\u003ecowboy_req:reply/4\u003c/code\u003e.\u003c/li\u003e\n\u003cli\u003eOpen one TCP connection and send a complete \u003ccode\u003eGET / HTTP/1.1\u003c/code\u003e request with a \u003ccode\u003eHost\u003c/code\u003e header.\u003c/li\u003e\n\u003cli\u003eWhile the handler is still running, keep writing arbitrary bytes on the same connection, for example 300 one-megabyte chunks.\u003c/li\u003e\n\u003cli\u003eInspect the connection process with \u003ccode\u003eerlang:process_info/2\u003c/code\u003e while the handler runs: its referenced binaries grow by the amount sent and \u003ccode\u003eerlang:memory(binary)\u003c/code\u003e rises by the same amount. No parser limit or timeout interrupts the transfer.\u003c/li\u003e\n\u003cli\u003eWhen the handler replies, the client still receives the \u003ccode\u003e200 OK\u003c/code\u003e; only afterwards does the buffered data get parsed and rejected.\u003c/li\u003e\n\u003c/ol\u003e\n\u003cp\u003eOn 2.16.1 and 2.17.0 step 3 is cut short: the server answers \u003ccode\u003e414 Request-URI Too Long\u003c/code\u003e with \u003ccode\u003econnection: close\u003c/code\u003e as soon as the first chunk arrives and holds nothing.\u003c/p\u003e"
},
{
"base64": false,
"type": "text/markdown",
"value": "1. Start a cowboy HTTP/1.1 listener with a handler that takes time to reply, for example one that sleeps 20 seconds before calling `cowboy_req:reply/4`.\n2. Open one TCP connection and send a complete `GET / HTTP/1.1` request with a `Host` header.\n3. While the handler is still running, keep writing arbitrary bytes on the same connection, for example 300 one-megabyte chunks.\n4. Inspect the connection process with `erlang:process_info/2` while the handler runs: its referenced binaries grow by the amount sent and `erlang:memory(binary)` rises by the same amount. No parser limit or timeout interrupts the transfer.\n5. When the handler replies, the client still receives the `200 OK`; only afterwards does the buffered data get parsed and rejected.\n\nOn 2.16.1 and 2.17.0 step 3 is cut short: the server answers `414 Request-URI Too Long` with `connection: close` as soon as the first chunk arrives and holds nothing."
}
],
"value": "* Start a cowboy HTTP/1.1 listener with a handler that takes time to reply, for example one that sleeps 20 seconds before calling cowboy_req:reply/4.\n* Open one TCP connection and send a complete GET / HTTP/1.1 request with a Host header.\n* While the handler is still running, keep writing arbitrary bytes on the same connection, for example 300 one-megabyte chunks.\n* Inspect the connection process with erlang:process_info/2 while the handler runs: its referenced binaries grow by the amount sent and erlang:memory(binary) rises by the same amount. No parser limit or timeout interrupts the transfer.\n* When the handler replies, the client still receives the 200 OK; only afterwards does the buffered data get parsed and rejected.\n\nOn 2.16.1 and 2.17.0 step 3 is cut short: the server answers 414 Request-URI Too Long with connection: close as soon as the first chunk arrives and holds nothing."
}
],
"x_technicalAnalysis": [
{
"lang": "en",
"supportingMedia": [
{
"base64": false,
"type": "text/html",
"value": "\u003cp\u003e\u003cstrong\u003e1. Request completes, handler runs.\u003c/strong\u003e When \u003ccode\u003ecowboy_http\u003c/code\u003e finishes reading a request it moves the parser to the \u003ccode\u003eps_next_request\u003c/code\u003e state while the stream for that request is still alive. The connection process keeps the socket in active mode (\u003ccode\u003esetopts_active/1\u003c/code\u003e re-arms \u003ccode\u003e{active, N}\u003c/code\u003e whenever a passive notification arrives), so the VM continues to deliver socket data to the process.\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003e2. Data is appended, never parsed.\u003c/strong\u003e Each incoming chunk reaches \u003ccode\u003eloop/1\u003c/code\u003e, which concatenates it onto the connection buffer and calls \u003ccode\u003eparse/2\u003c/code\u003e. The \u003ccode\u003eparse/2\u003c/code\u003e clause for \u003ccode\u003ein_state=ps_next_request\u003c/code\u003e with a live \u003ccode\u003e#stream{}\u003c/code\u003e returns straight to \u003ccode\u003ebefore_loop/1\u003c/code\u003e with the enlarged buffer, deferring all parsing until the current stream stops. The parser limits (\u003ccode\u003emax_request_line_length\u003c/code\u003e, \u003ccode\u003emax_header_name_length\u003c/code\u003e, \u003ccode\u003emax_header_value_length\u003c/code\u003e, \u003ccode\u003emax_headers\u003c/code\u003e) are enforced only by the parsing clauses that run after that point, and \u003ccode\u003emax_skip_body_length\u003c/code\u003e only covers an unread body of the current request, not bytes after it.\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003e3. No timer bounds the window.\u003c/strong\u003e While a stream is live the connection runs on \u003ccode\u003eidle_timeout\u003c/code\u003e (default 60 seconds), and \u003ccode\u003eset_timeout/2\u003c/code\u003e resets it on every received chunk. The attacker therefore keeps the window open by continuing to send. Only the handler finishing ends it, at which point parsing resumes, the buffered bytes fail the request-line checks, and the connection closes with a 4xx response. By then the data has been held for the full handler duration.\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003e4. Introduction.\u003c/strong\u003e The behaviour arrived with the change that removed concurrent processing of pipelined HTTP/1.1 requests (first shipped in 2.18.0). Earlier releases parsed pipelined data as it arrived, so excess bytes hit the request-line limit immediately and the connection was closed with \u003ccode\u003e414 Request-URI Too Long\u003c/code\u003e before any significant amount was buffered. The introducing commit describes the intended behaviour as stopping reads from the socket until the stream stops, but the code keeps the socket active and buffers instead.\u003c/p\u003e"
},
{
"base64": false,
"type": "text/markdown",
"value": "**1. Request completes, handler runs.** When `cowboy_http` finishes reading a request it moves the parser to the `ps_next_request` state while the stream for that request is still alive. The connection process keeps the socket in active mode (`setopts_active/1` re-arms `{active, N}` whenever a passive notification arrives), so the VM continues to deliver socket data to the process.\n\n**2. Data is appended, never parsed.** Each incoming chunk reaches `loop/1`, which concatenates it onto the connection buffer and calls `parse/2`. The `parse/2` clause for `in_state=ps_next_request` with a live `#stream{}` returns straight to `before_loop/1` with the enlarged buffer, deferring all parsing until the current stream stops. The parser limits (`max_request_line_length`, `max_header_name_length`, `max_header_value_length`, `max_headers`) are enforced only by the parsing clauses that run after that point, and `max_skip_body_length` only covers an unread body of the current request, not bytes after it.\n\n**3. No timer bounds the window.** While a stream is live the connection runs on `idle_timeout` (default 60 seconds), and `set_timeout/2` resets it on every received chunk. The attacker therefore keeps the window open by continuing to send. Only the handler finishing ends it, at which point parsing resumes, the buffered bytes fail the request-line checks, and the connection closes with a 4xx response. By then the data has been held for the full handler duration.\n\n**4. Introduction.** The behaviour arrived with the change that removed concurrent processing of pipelined HTTP/1.1 requests (first shipped in 2.18.0). Earlier releases parsed pipelined data as it arrived, so excess bytes hit the request-line limit immediately and the connection was closed with `414 Request-URI Too Long` before any significant amount was buffered. The introducing commit describes the intended behaviour as stopping reads from the socket until the stream stops, but the code keeps the socket active and buffers instead."
}
],
"value": "1. Request completes, handler runs. When cowboy_http finishes reading a request it moves the parser to the ps_next_request state while the stream for that request is still alive. The connection process keeps the socket in active mode (setopts_active/1 re-arms {active, N} whenever a passive notification arrives), so the VM continues to deliver socket data to the process.\n\n2. Data is appended, never parsed. Each incoming chunk reaches loop/1, which concatenates it onto the connection buffer and calls parse/2. The parse/2 clause for in_state=ps_next_request with a live #stream{} returns straight to before_loop/1 with the enlarged buffer, deferring all parsing until the current stream stops. The parser limits (max_request_line_length, max_header_name_length, max_header_value_length, max_headers) are enforced only by the parsing clauses that run after that point, and max_skip_body_length only covers an unread body of the current request, not bytes after it.\n\n3. No timer bounds the window. While a stream is live the connection runs on idle_timeout (default 60 seconds), and set_timeout/2 resets it on every received chunk. The attacker therefore keeps the window open by continuing to send. Only the handler finishing ends it, at which point parsing resumes, the buffered bytes fail the request-line checks, and the connection closes with a 4xx response. By then the data has been held for the full handler duration.\n\n4. Introduction. The behaviour arrived with the change that removed concurrent processing of pipelined HTTP/1.1 requests (first shipped in 2.18.0). Earlier releases parsed pipelined data as it arrived, so excess bytes hit the request-line limit immediately and the connection was closed with 414 Request-URI Too Long before any significant amount was buffered. The introducing commit describes the intended behaviour as stopping reads from the socket until the stream stops, but the code keeps the socket active and buffers instead."
}
]
}
},
"cveMetadata": {
"assignerOrgId": "6b3ad84c-e1a6-4bf7-a703-f496b71e49db",
"assignerShortName": "EEF",
"cveId": "CVE-2026-115701",
"datePublished": "2026-11-06T10:29:00.314Z",
"dateReserved": "2026-10-26T10:30:01.636Z",
"dateUpdated": "2026-11-10T15:02:23.747Z",
"state": "PUBLISHED"
},
"dataType": "CVE_RECORD",
"dataVersion": "5.2"
}
CVE-2026-113431 (GCVE-0-2026-113431)
Vulnerability from cvelistv5 – Published: 2026-11-06 10:28 – Updated: 2026-11-10 15:03
VLAI
EPSS
VEX
Title
Unbounded decoded HTTP/2 header-list size in cowlib cow_http2_machine enables HPACK amplification memory exhaustion
Summary
Allocation of Resources Without Limits or Throttling vulnerability in ninenines cowlib allows an unauthenticated remote peer to exhaust the memory of an HTTP/2 endpoint via HPACK-indexed header fields.
cow_http2_machine:headers_decode/4 in src/cow_http2_machine.erl decodes every HEADERS block with cow_hpack:decode/3. It bounds the compressed block size and the number of fields, but not the size of the decoded header list that RFC 9113 section 6.5.2 places under SETTINGS_MAX_HEADER_LIST_SIZE. A peer stores one header value of up to the dynamic table size (4096 bytes by default) and then references it with one-byte indexed fields, so about 100 bytes on the wire decode to a header list of about 390 KB. A consumer that copies the decoded values, as Cowboy does when cowboy_http2:headers_to_map/2 joins repeated cookie fields into one binary, allocates that amount for every stream. The total across streams and connections is not bounded.
This issue affects cowlib: from 2.7.0 before 2.22.0.
Severity
SSVC
Exploitation: none
Automatable: yes
Technical Impact: partial
CISA Coordinator · CISA-ADP (v2.0.3)
Decision recorded 2026-11-10 15:02 UTC
CWE
- CWE-770 - Allocation of Resources Without Limits or Throttling
Assigner
References
4 references
| URL | Tags |
|---|---|
| https://cna.erlef.org/cves/CVE-2026-113431.html | relatedthird-party-advisory |
| https://osv.dev/vulnerability/EEF-CVE-2026-113431 | related |
| https://github.com/ninenines/cowlib/commit/b461b1… | related |
| https://github.com/ninenines/cowlib/commit/a3b15e… | patch |
Impacted products
Date Public
2026-11-06 10:28
{
"containers": {
"adp": [
{
"metrics": [
{
"other": {
"content": {
"id": "CVE-2026-113431",
"options": [
{
"Exploitation": "none"
},
{
"Automatable": "yes"
},
{
"Technical Impact": "partial"
}
],
"role": "CISA Coordinator",
"timestamp": "2026-11-10T15:02:48.422648Z",
"version": "2.0.3"
},
"type": "ssvc"
}
}
],
"providerMetadata": {
"dateUpdated": "2026-11-10T15:03:30.825Z",
"orgId": "134c704f-9b21-4f2e-91b3-4a467353bcc0",
"shortName": "CISA-ADP"
},
"title": "CISA ADP Vulnrichment"
}
],
"cna": {
"affected": [
{
"collectionURL": "https://repo.hex.pm",
"cpes": [
"cpe:2.3:a:ninenines:cowlib:*:*:*:*:*:*:*:*"
],
"defaultStatus": "unaffected",
"modules": [
"cow_http2_machine",
"cow_hpack"
],
"packageName": "cowlib",
"packageURL": "pkg:hex/cowlib",
"product": "cowlib",
"programFiles": [
"src/cow_http2_machine.erl",
"src/cow_hpack.erl"
],
"programRoutines": [
{
"name": "cow_http2_machine:headers_decode/4"
},
{
"name": "cow_http2_machine:frame/2"
},
{
"name": "cow_hpack:decode/3"
},
{
"name": "cow_hpack:decode/2"
}
],
"repo": "https://github.com/ninenines/cowlib",
"vendor": "ninenines",
"versions": [
{
"lessThan": "2.22.0",
"status": "affected",
"version": "2.7.0",
"versionType": "semver"
}
]
},
{
"collectionURL": "https://github.com",
"cpes": [
"cpe:2.3:a:ninenines:cowlib:*:*:*:*:*:*:*:*"
],
"defaultStatus": "unaffected",
"modules": [
"cow_http2_machine",
"cow_hpack"
],
"packageName": "ninenines/cowlib",
"packageURL": "pkg:github/ninenines/cowlib",
"product": "cowlib",
"programFiles": [
"src/cow_http2_machine.erl",
"src/cow_hpack.erl"
],
"programRoutines": [
{
"name": "cow_http2_machine:headers_decode/4"
},
{
"name": "cow_http2_machine:frame/2"
},
{
"name": "cow_hpack:decode/3"
},
{
"name": "cow_hpack:decode/2"
}
],
"repo": "https://github.com/ninenines/cowlib",
"vendor": "ninenines",
"versions": [
{
"lessThan": "a3b15e29b3067644bfd580246c87f7af7a90b3b8",
"status": "affected",
"version": "b461b119e78e4e09bb28b186b09da7ed4a86a0dd",
"versionType": "git"
}
]
}
],
"cpeApplicability": [
{
"nodes": [
{
"cpeMatch": [
{
"criteria": "cpe:2.3:a:ninenines:cowlib:*:*:*:*:*:*:*:*",
"versionEndExcluding": "2.22.0",
"versionStartIncluding": "2.7.0",
"vulnerable": true
}
],
"negate": false,
"operator": "OR"
}
],
"operator": "AND"
}
],
"credits": [
{
"lang": "en",
"type": "finder",
"value": "Andrew Moore"
},
{
"lang": "en",
"type": "reporter",
"value": "Andrew Moore"
},
{
"lang": "en",
"type": "remediation developer",
"value": "Lo\u00efc Hoguin"
},
{
"lang": "en",
"type": "coordinator",
"value": "Jonatan M\u00e4nnchen / EEF"
}
],
"dateAssigned": "2026-10-30T12:35:17.000Z",
"datePublic": "2026-11-06T10:28:16.000Z",
"descriptions": [
{
"lang": "en",
"supportingMedia": [
{
"base64": false,
"type": "text/html",
"value": "\u003cp\u003eAllocation of Resources Without Limits or Throttling vulnerability in ninenines cowlib allows an unauthenticated remote peer to exhaust the memory of an HTTP/2 endpoint via HPACK-indexed header fields.\u003c/p\u003e\n\u003cp\u003e\u003ccode\u003ecow_http2_machine:headers_decode/4\u003c/code\u003e in \u003ccode\u003esrc/cow_http2_machine.erl\u003c/code\u003e decodes every HEADERS block with \u003ccode\u003ecow_hpack:decode/3\u003c/code\u003e. It bounds the compressed block size and the number of fields, but not the size of the decoded header list that RFC 9113 section 6.5.2 places under \u003ccode\u003eSETTINGS_MAX_HEADER_LIST_SIZE\u003c/code\u003e. A peer stores one header value of up to the dynamic table size (4096 bytes by default) and then references it with one-byte indexed fields, so about 100 bytes on the wire decode to a header list of about 390 KB. A consumer that copies the decoded values, as Cowboy does when \u003ccode\u003ecowboy_http2:headers_to_map/2\u003c/code\u003e joins repeated \u003ccode\u003ecookie\u003c/code\u003e fields into one binary, allocates that amount for every stream. The total across streams and connections is not bounded.\u003c/p\u003e\n\u003cp\u003eThis issue affects cowlib: from 2.7.0 before 2.22.0.\u003c/p\u003e"
},
{
"base64": false,
"type": "text/markdown",
"value": "Allocation of Resources Without Limits or Throttling vulnerability in ninenines cowlib allows an unauthenticated remote peer to exhaust the memory of an HTTP/2 endpoint via HPACK-indexed header fields.\n\n`cow_http2_machine:headers_decode/4` in `src/cow_http2_machine.erl` decodes every HEADERS block with `cow_hpack:decode/3`. It bounds the compressed block size and the number of fields, but not the size of the decoded header list that RFC 9113 section 6.5.2 places under `SETTINGS_MAX_HEADER_LIST_SIZE`. A peer stores one header value of up to the dynamic table size (4096 bytes by default) and then references it with one-byte indexed fields, so about 100 bytes on the wire decode to a header list of about 390 KB. A consumer that copies the decoded values, as Cowboy does when `cowboy_http2:headers_to_map/2` joins repeated `cookie` fields into one binary, allocates that amount for every stream. The total across streams and connections is not bounded.\n\nThis issue affects cowlib: from 2.7.0 before 2.22.0."
}
],
"value": "Allocation of Resources Without Limits or Throttling vulnerability in ninenines cowlib allows an unauthenticated remote peer to exhaust the memory of an HTTP/2 endpoint via HPACK-indexed header fields.\n\ncow_http2_machine:headers_decode/4 in src/cow_http2_machine.erl decodes every HEADERS block with cow_hpack:decode/3. It bounds the compressed block size and the number of fields, but not the size of the decoded header list that RFC 9113 section 6.5.2 places under SETTINGS_MAX_HEADER_LIST_SIZE. A peer stores one header value of up to the dynamic table size (4096 bytes by default) and then references it with one-byte indexed fields, so about 100 bytes on the wire decode to a header list of about 390 KB. A consumer that copies the decoded values, as Cowboy does when cowboy_http2:headers_to_map/2 joins repeated cookie fields into one binary, allocates that amount for every stream. The total across streams and connections is not bounded.\n\nThis issue affects cowlib: from 2.7.0 before 2.22.0."
}
],
"impacts": [
{
"capecId": "CAPEC-491",
"descriptions": [
{
"lang": "en",
"supportingMedia": [
{
"base64": false,
"type": "text/html",
"value": "\u003cp\u003eA remote unauthenticated client makes an HTTP/2 server built on cowlib, such as a Cowboy listener, allocate roughly 2,400 times the bytes it sends. One connection reaches about 38 MB with default settings, and an attacker holding many connections can drive the Erlang VM out of memory.\u003c/p\u003e"
},
{
"base64": false,
"type": "text/markdown",
"value": "A remote unauthenticated client makes an HTTP/2 server built on cowlib, such as a Cowboy listener, allocate roughly 2,400 times the bytes it sends. One connection reaches about 38 MB with default settings, and an attacker holding many connections can drive the Erlang VM out of memory."
}
],
"value": "A remote unauthenticated client makes an HTTP/2 server built on cowlib, such as a Cowboy listener, allocate roughly 2,400 times the bytes it sends. One connection reaches about 38 MB with default settings, and an attacker holding many connections can drive the Erlang VM out of memory."
}
]
}
],
"metrics": [
{
"cvssV4_0": {
"Automatable": "NOT_DEFINED",
"Recovery": "NOT_DEFINED",
"Safety": "NOT_DEFINED",
"attackComplexity": "LOW",
"attackRequirements": "NONE",
"attackVector": "NETWORK",
"baseScore": 8.7,
"baseSeverity": "HIGH",
"privilegesRequired": "NONE",
"providerUrgency": "NOT_DEFINED",
"subAvailabilityImpact": "NONE",
"subConfidentialityImpact": "NONE",
"subIntegrityImpact": "NONE",
"userInteraction": "NONE",
"valueDensity": "NOT_DEFINED",
"vectorString": "CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:N/VI:N/VA:H/SC:N/SI:N/SA:N",
"version": "4.0",
"vulnAvailabilityImpact": "HIGH",
"vulnConfidentialityImpact": "NONE",
"vulnIntegrityImpact": "NONE",
"vulnerabilityResponseEffort": "NOT_DEFINED"
},
"format": "CVSS",
"scenarios": [
{
"lang": "en",
"value": "GENERAL"
}
]
}
],
"problemTypes": [
{
"descriptions": [
{
"cweId": "CWE-770",
"description": "CWE-770 Allocation of Resources Without Limits or Throttling",
"lang": "en",
"type": "CWE"
}
]
}
],
"providerMetadata": {
"dateUpdated": "2026-11-09T15:27:14.870Z",
"orgId": "6b3ad84c-e1a6-4bf7-a703-f496b71e49db",
"shortName": "EEF"
},
"references": [
{
"name": "EEF CNA record for CVE-2026-113431",
"tags": [
"related",
"third-party-advisory"
],
"url": "https://cna.erlef.org/cves/CVE-2026-113431.html"
},
{
"name": "OSV record EEF-CVE-2026-113431",
"tags": [
"related"
],
"url": "https://osv.dev/vulnerability/EEF-CVE-2026-113431"
},
{
"name": "Introducing commit b461b11 in ninenines/cowlib",
"tags": [
"related"
],
"url": "https://github.com/ninenines/cowlib/commit/b461b119e78e4e09bb28b186b09da7ed4a86a0dd"
},
{
"name": "Fix commit a3b15e2 in ninenines/cowlib",
"tags": [
"patch"
],
"url": "https://github.com/ninenines/cowlib/commit/a3b15e29b3067644bfd580246c87f7af7a90b3b8"
}
],
"source": {
"discovery": "EXTERNAL"
},
"title": "Unbounded decoded HTTP/2 header-list size in cowlib cow_http2_machine enables HPACK amplification memory exhaustion",
"workarounds": [
{
"lang": "en",
"supportingMedia": [
{
"base64": false,
"type": "text/html",
"value": "\u003cp\u003eCowboy servers that do not need HTTP/2 can set \u003ccode\u003eprotocols =\u0026gt; [http]\u003c/code\u003e in the protocol options. The listener then answers the HTTP/2 preface with \u003ccode\u003e501 Not Implemented\u003c/code\u003e and the decoder is never reached.\u003c/p\u003e\n\u003cp\u003eLowering \u003ccode\u003emax_headers\u003c/code\u003e in the HTTP/2 protocol options shrinks the per-stream allocation in proportion, to about \u003ccode\u003emax_headers\u003c/code\u003e times the 4096-byte table entry. This reduces the amplification but does not remove it.\u003c/p\u003e"
},
{
"base64": false,
"type": "text/markdown",
"value": "Cowboy servers that do not need HTTP/2 can set `protocols =\u003e [http]` in the protocol options. The listener then answers the HTTP/2 preface with `501 Not Implemented` and the decoder is never reached.\n\nLowering `max_headers` in the HTTP/2 protocol options shrinks the per-stream allocation in proportion, to about `max_headers` times the 4096-byte table entry. This reduces the amplification but does not remove it."
}
],
"value": "Cowboy servers that do not need HTTP/2 can set protocols =\u003e [http] in the protocol options. The listener then answers the HTTP/2 preface with 501 Not Implemented and the decoder is never reached.\n\nLowering max_headers in the HTTP/2 protocol options shrinks the per-stream allocation in proportion, to about max_headers times the 4096-byte table entry. This reduces the amplification but does not remove it."
}
],
"x_proofOfConcept": [
{
"lang": "en",
"supportingMedia": [
{
"base64": false,
"type": "text/html",
"value": "\u003col\u003e\n\u003cli\u003eStart a Cowboy clear listener with default protocol options and a handler that takes a few seconds to reply, so streams stay open.\u003c/li\u003e\n\u003cli\u003eOpen one TCP connection, send the HTTP/2 client preface and an empty SETTINGS frame.\u003c/li\u003e\n\u003cli\u003eOn stream 1 send a HEADERS frame with \u003ccode\u003eEND_HEADERS\u003c/code\u003e and \u003ccode\u003eEND_STREAM\u003c/code\u003e whose block contains \u003ccode\u003e:method\u003c/code\u003e, \u003ccode\u003e:scheme\u003c/code\u003e, \u003ccode\u003e:path\u003c/code\u003e and \u003ccode\u003e:authority\u003c/code\u003e, then one literal \u003ccode\u003ecookie\u003c/code\u003e field with incremental indexing carrying a 4000-byte value, then 95 indexed fields \u003ccode\u003e0xBE\u003c/code\u003e referencing it.\u003c/li\u003e\n\u003cli\u003eOn streams 3 through 199 send the same frame with the four pseudo-headers followed by 96 indexed fields \u003ccode\u003e0xBE\u003c/code\u003e. Each block is 110 bytes.\u003c/li\u003e\n\u003cli\u003eWatch \u003ccode\u003eerlang:memory(binary)\u003c/code\u003e on the server. It grows by about 38 MB for about 16 KB sent, and \u003ccode\u003ecowboy_req:header(\u0026lt;\u0026lt;\"cookie\"\u0026gt;\u0026gt;, Req)\u003c/code\u003e in each handler returns a 384,190-byte binary.\u003c/li\u003e\n\u003c/ol\u003e"
},
{
"base64": false,
"type": "text/markdown",
"value": "1. Start a Cowboy clear listener with default protocol options and a handler that takes a few seconds to reply, so streams stay open.\n2. Open one TCP connection, send the HTTP/2 client preface and an empty SETTINGS frame.\n3. On stream 1 send a HEADERS frame with `END_HEADERS` and `END_STREAM` whose block contains `:method`, `:scheme`, `:path` and `:authority`, then one literal `cookie` field with incremental indexing carrying a 4000-byte value, then 95 indexed fields `0xBE` referencing it.\n4. On streams 3 through 199 send the same frame with the four pseudo-headers followed by 96 indexed fields `0xBE`. Each block is 110 bytes.\n5. Watch `erlang:memory(binary)` on the server. It grows by about 38 MB for about 16 KB sent, and `cowboy_req:header(\u003c\u003c\"cookie\"\u003e\u003e, Req)` in each handler returns a 384,190-byte binary."
}
],
"value": "* Start a Cowboy clear listener with default protocol options and a handler that takes a few seconds to reply, so streams stay open.\n* Open one TCP connection, send the HTTP/2 client preface and an empty SETTINGS frame.\n* On stream 1 send a HEADERS frame with END_HEADERS and END_STREAM whose block contains :method, :scheme, :path and :authority, then one literal cookie field with incremental indexing carrying a 4000-byte value, then 95 indexed fields 0xBE referencing it.\n* On streams 3 through 199 send the same frame with the four pseudo-headers followed by 96 indexed fields 0xBE. Each block is 110 bytes.\n* Watch erlang:memory(binary) on the server. It grows by about 38 MB for about 16 KB sent, and cowboy_req:header(\u003c\u003c\"cookie\"\u003e\u003e, Req) in each handler returns a 384,190-byte binary."
}
],
"x_technicalAnalysis": [
{
"lang": "en",
"supportingMedia": [
{
"base64": false,
"type": "text/html",
"value": "\u003cp\u003e\u003cstrong\u003e1. The decoder bounds everything but the decoded size.\u003c/strong\u003e \u003ccode\u003ecow_http2_machine:headers_decode/4\u003c/code\u003e passes each complete header block to \u003ccode\u003ecow_hpack:decode/3\u003c/code\u003e. Two limits apply: \u003ccode\u003emax_fragmented_header_block_size\u003c/code\u003e (32768 bytes by default) on the compressed block, and \u003ccode\u003emax_headers\u003c/code\u003e (100 by default, added in cowlib 2.17.0) on the number of fields. \u003ccode\u003emax_header_list_size\u003c/code\u003e appears in the module\u0027s option list with a default of \u003ccode\u003einfinity\u003c/code\u003e, next to a \u003ccode\u003e@todo\u003c/code\u003e comment, and is neither advertised in SETTINGS nor enforced on the decoded list, which is what RFC 9113 section 6.5.2 defines it for.\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003e2. One-byte fields expand to kilobytes.\u003c/strong\u003e The dynamic table holds entries of up to \u003ccode\u003emax_decode_table_size\u003c/code\u003e (4096 bytes by default). The peer sends one literal field with incremental indexing carrying a 4000-byte value, which becomes table entry 62. Every later field is the single byte \u003ccode\u003e0xBE\u003c/code\u003e, an indexed representation of that entry. A 110-byte block that carries four pseudo-headers and 96 such references decodes to a 100-field list whose values total 384,000 bytes. Before 2.17.0 no field count applied, so a 32 KB block referenced the entry about 32,000 times.\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003e3. The allocation is realised by the consumer.\u003c/strong\u003e The list that \u003ccode\u003ecow_hpack\u003c/code\u003e returns is cheap, about 2 KB of heap, because every reference shares the one table binary. Cowboy\u0027s \u003ccode\u003ecowboy_http2:headers_to_map/2\u003c/code\u003e joins repeated fields into a single new binary (\u003ccode\u003e\"; \"\u003c/code\u003e for \u003ccode\u003ecookie\u003c/code\u003e, \u003ccode\u003e\", \"\u003c/code\u003e for other names), so each stream yields a 384,190-byte cookie value that lives in the request process for the life of the stream. Cowboy\u0027s HTTP/1.1 limits \u003ccode\u003emax_header_value_length\u003c/code\u003e and \u003ccode\u003emax_headers\u003c/code\u003e do not apply to HTTP/2. With the default \u003ccode\u003emax_concurrent_streams\u003c/code\u003e of 100 a connection holds about 38 MB, and only the Ranch connection limit bounds the total. Gun hands the decoded list to the owner process without copying the values, so the client side sees a small list per response and no amplification.\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003e4. Measurement.\u003c/strong\u003e On cowboy 2.20.0 with cowlib 2.21.0 on Erlang/OTP 29, one h2c connection on loopback sent 15,943 bytes of HEADERS for 100 streams. Binary memory grew by 38.4 MB and every handler received a 384,190-byte \u003ccode\u003ecookie\u003c/code\u003e header. A control with the same 100 streams and no indexed fields grew by 0.\u003c/p\u003e"
},
{
"base64": false,
"type": "text/markdown",
"value": "**1. The decoder bounds everything but the decoded size.** `cow_http2_machine:headers_decode/4` passes each complete header block to `cow_hpack:decode/3`. Two limits apply: `max_fragmented_header_block_size` (32768 bytes by default) on the compressed block, and `max_headers` (100 by default, added in cowlib 2.17.0) on the number of fields. `max_header_list_size` appears in the module\u0027s option list with a default of `infinity`, next to a `@todo` comment, and is neither advertised in SETTINGS nor enforced on the decoded list, which is what RFC 9113 section 6.5.2 defines it for.\n\n**2. One-byte fields expand to kilobytes.** The dynamic table holds entries of up to `max_decode_table_size` (4096 bytes by default). The peer sends one literal field with incremental indexing carrying a 4000-byte value, which becomes table entry 62. Every later field is the single byte `0xBE`, an indexed representation of that entry. A 110-byte block that carries four pseudo-headers and 96 such references decodes to a 100-field list whose values total 384,000 bytes. Before 2.17.0 no field count applied, so a 32 KB block referenced the entry about 32,000 times.\n\n**3. The allocation is realised by the consumer.** The list that `cow_hpack` returns is cheap, about 2 KB of heap, because every reference shares the one table binary. Cowboy\u0027s `cowboy_http2:headers_to_map/2` joins repeated fields into a single new binary (`\"; \"` for `cookie`, `\", \"` for other names), so each stream yields a 384,190-byte cookie value that lives in the request process for the life of the stream. Cowboy\u0027s HTTP/1.1 limits `max_header_value_length` and `max_headers` do not apply to HTTP/2. With the default `max_concurrent_streams` of 100 a connection holds about 38 MB, and only the Ranch connection limit bounds the total. Gun hands the decoded list to the owner process without copying the values, so the client side sees a small list per response and no amplification.\n\n**4. Measurement.** On cowboy 2.20.0 with cowlib 2.21.0 on Erlang/OTP 29, one h2c connection on loopback sent 15,943 bytes of HEADERS for 100 streams. Binary memory grew by 38.4 MB and every handler received a 384,190-byte `cookie` header. A control with the same 100 streams and no indexed fields grew by 0."
}
],
"value": "1. The decoder bounds everything but the decoded size. cow_http2_machine:headers_decode/4 passes each complete header block to cow_hpack:decode/3. Two limits apply: max_fragmented_header_block_size (32768 bytes by default) on the compressed block, and max_headers (100 by default, added in cowlib 2.17.0) on the number of fields. max_header_list_size appears in the module\u0027s option list with a default of infinity, next to a @todo comment, and is neither advertised in SETTINGS nor enforced on the decoded list, which is what RFC 9113 section 6.5.2 defines it for.\n\n2. One-byte fields expand to kilobytes. The dynamic table holds entries of up to max_decode_table_size (4096 bytes by default). The peer sends one literal field with incremental indexing carrying a 4000-byte value, which becomes table entry 62. Every later field is the single byte 0xBE, an indexed representation of that entry. A 110-byte block that carries four pseudo-headers and 96 such references decodes to a 100-field list whose values total 384,000 bytes. Before 2.17.0 no field count applied, so a 32 KB block referenced the entry about 32,000 times.\n\n3. The allocation is realised by the consumer. The list that cow_hpack returns is cheap, about 2 KB of heap, because every reference shares the one table binary. Cowboy\u0027s cowboy_http2:headers_to_map/2 joins repeated fields into a single new binary (\"; \" for cookie, \", \" for other names), so each stream yields a 384,190-byte cookie value that lives in the request process for the life of the stream. Cowboy\u0027s HTTP/1.1 limits max_header_value_length and max_headers do not apply to HTTP/2. With the default max_concurrent_streams of 100 a connection holds about 38 MB, and only the Ranch connection limit bounds the total. Gun hands the decoded list to the owner process without copying the values, so the client side sees a small list per response and no amplification.\n\n4. Measurement. On cowboy 2.20.0 with cowlib 2.21.0 on Erlang/OTP 29, one h2c connection on loopback sent 15,943 bytes of HEADERS for 100 streams. Binary memory grew by 38.4 MB and every handler received a 384,190-byte cookie header. A control with the same 100 streams and no indexed fields grew by 0."
}
]
}
},
"cveMetadata": {
"assignerOrgId": "6b3ad84c-e1a6-4bf7-a703-f496b71e49db",
"assignerShortName": "EEF",
"cveId": "CVE-2026-113431",
"datePublished": "2026-11-06T10:28:15.837Z",
"dateReserved": "2026-10-30T12:30:02.006Z",
"dateUpdated": "2026-11-10T15:03:30.825Z",
"state": "PUBLISHED"
},
"dataType": "CVE_RECORD",
"dataVersion": "5.2"
}
CVE-2026-66294 (GCVE-0-2026-66294)
Vulnerability from cvelistv5 – Published: 2026-11-06 10:16 – Updated: 2026-11-10 17:43
VLAI
EPSS
VEX
Title
XML injection in Saxy via unescaped CDATA section terminator
Summary
XML injection in the Saxy XML encoder. Saxy.Encoder.cdata/1 serializes a {:cdata, characters} node by wrapping the supplied characters between <![CDATA[ and ]]> and passing the payload through byte for byte. The encoder's character-data and attribute-value paths escape the XML metacharacters <, >, &, ", and ', but the CDATA path applies no neutralization at all. XML provides no character escaping mechanism inside a CDATA section, so the only correct way to represent a ]]> substring is to split the section and rejoin it as ]]]]><![CDATA[>. Saxy neither performs that split nor rejects the substring.
An application that renders untrusted text as CDATA, by building the node with Saxy.XML.cdata/1 and serializing it with Saxy.encode!/2 or Saxy.encode_to_iodata!/2, therefore lets an attacker terminate the section at a position of their choosing. Everything supplied after the ]]> is emitted outside any CDATA context and is parsed as markup, so a single attacker-controlled text field becomes attacker-controlled document structure: arbitrary sibling elements and attributes that the application never built. Downstream consumers of the serialized document accept the injected markup as genuine. Services that generate SOAP envelopes, RSS or Atom feeds, sitemaps, SVG, or XML exports from user-supplied content can have trusted fields forged or overridden. Applications that never place untrusted text in a CDATA node are not affected.
This issue affects saxy: from 0.7.0 before 1.7.0.
Severity
SSVC
Exploitation: none
Automatable: no
Technical Impact: partial
CISA Coordinator · CISA-ADP (v2.0.3)
Decision recorded 2026-11-10 17:43 UTC
CWE
- CWE-91 - XML Injection
Assigner
References
5 references
| URL | Tags |
|---|---|
| https://github.com/qcam/saxy/security/advisories/… | vendor-advisoryrelated |
| https://cna.erlef.org/cves/CVE-2026-66294.html | related |
| https://osv.dev/vulnerability/EEF-CVE-2026-66294 | related |
| https://github.com/qcam/saxy/commit/7266632e94078… | related |
| https://github.com/qcam/saxy/commit/71d99fae12f1d… | patch |
Impacted products
{
"containers": {
"adp": [
{
"metrics": [
{
"other": {
"content": {
"id": "CVE-2026-66294",
"options": [
{
"Exploitation": "none"
},
{
"Automatable": "no"
},
{
"Technical Impact": "partial"
}
],
"role": "CISA Coordinator",
"timestamp": "2026-11-10T17:43:23.760979Z",
"version": "2.0.3"
},
"type": "ssvc"
}
}
],
"providerMetadata": {
"dateUpdated": "2026-11-10T17:43:39.205Z",
"orgId": "134c704f-9b21-4f2e-91b3-4a467353bcc0",
"shortName": "CISA-ADP"
},
"title": "CISA ADP Vulnrichment"
}
],
"cna": {
"affected": [
{
"collectionURL": "https://repo.hex.pm",
"cpes": [
"cpe:2.3:a:qcam:saxy:*:*:*:*:*:*:*:*"
],
"defaultStatus": "unaffected",
"modules": [
"\u0027Elixir.Saxy\u0027",
"\u0027Elixir.Saxy.Encoder\u0027",
"\u0027Elixir.Saxy.XML\u0027"
],
"packageName": "saxy",
"packageURL": "pkg:hex/saxy",
"product": "saxy",
"programFiles": [
"lib/saxy.ex",
"lib/saxy/encoder.ex",
"lib/saxy/xml.ex"
],
"programRoutines": [
{
"name": "\u0027Elixir.Saxy\u0027:encode!/2"
},
{
"name": "\u0027Elixir.Saxy\u0027:encode_to_iodata!/2"
},
{
"name": "\u0027Elixir.Saxy.Encoder\u0027:encode_to_iodata/2"
},
{
"name": "\u0027Elixir.Saxy.Encoder\u0027:cdata/1"
},
{
"name": "\u0027Elixir.Saxy.XML\u0027:cdata/1"
}
],
"repo": "https://github.com/qcam/saxy",
"vendor": "qcam",
"versions": [
{
"lessThan": "1.7.0",
"status": "affected",
"version": "0.7.0",
"versionType": "semver"
}
]
},
{
"collectionURL": "https://github.com",
"cpes": [
"cpe:2.3:a:qcam:saxy:*:*:*:*:*:*:*:*"
],
"defaultStatus": "unaffected",
"modules": [
"\u0027Elixir.Saxy\u0027",
"\u0027Elixir.Saxy.Encoder\u0027",
"\u0027Elixir.Saxy.XML\u0027"
],
"packageName": "qcam/saxy",
"packageURL": "pkg:github/qcam/saxy",
"product": "saxy",
"programFiles": [
"lib/saxy.ex",
"lib/saxy/encoder.ex",
"lib/saxy/xml.ex"
],
"programRoutines": [
{
"name": "\u0027Elixir.Saxy\u0027:encode!/2"
},
{
"name": "\u0027Elixir.Saxy\u0027:encode_to_iodata!/2"
},
{
"name": "\u0027Elixir.Saxy.Encoder\u0027:encode_to_iodata/2"
},
{
"name": "\u0027Elixir.Saxy.Encoder\u0027:cdata/1"
},
{
"name": "\u0027Elixir.Saxy.XML\u0027:cdata/1"
}
],
"repo": "https://github.com/qcam/saxy",
"vendor": "qcam",
"versions": [
{
"lessThan": "71d99fae12f1d6596f5edb57b71f26ba854f4d33",
"status": "affected",
"version": "7266632e9407842f28ffac254c56dc41210a5690",
"versionType": "git"
}
]
}
],
"cpeApplicability": [
{
"nodes": [
{
"cpeMatch": [
{
"criteria": "cpe:2.3:a:qcam:saxy:*:*:*:*:*:*:*:*",
"versionEndExcluding": "1.7.0",
"versionStartIncluding": "0.7.0",
"vulnerable": true
}
],
"negate": false,
"operator": "OR"
}
],
"operator": "AND"
}
],
"credits": [
{
"lang": "en",
"type": "finder",
"value": "Peter Ullrich"
},
{
"lang": "en",
"type": "reporter",
"value": "Peter Ullrich"
},
{
"lang": "en",
"type": "remediation developer",
"value": "Peter Ullrich"
},
{
"lang": "en",
"type": "remediation reviewer",
"value": "C\u1ea9m Hu\u1ef3nh"
},
{
"lang": "en",
"type": "analyst",
"value": "Jonatan M\u00e4nnchen / EEF"
},
{
"lang": "en",
"type": "coordinator",
"value": "Peter Ullrich"
}
],
"descriptions": [
{
"lang": "en",
"supportingMedia": [
{
"base64": false,
"type": "text/html",
"value": "\u003cp\u003eXML injection in the Saxy XML encoder. \u003ccode\u003eSaxy.Encoder.cdata/1\u003c/code\u003e serializes a \u003ccode\u003e{:cdata, characters}\u003c/code\u003e node by wrapping the supplied characters between \u003ccode\u003e\u0026lt;![CDATA[\u003c/code\u003e and \u003ccode\u003e]]\u0026gt;\u003c/code\u003e and passing the payload through byte for byte. The encoder\u0027s character-data and attribute-value paths escape the XML metacharacters \u003ccode\u003e\u0026lt;\u003c/code\u003e, \u003ccode\u003e\u0026gt;\u003c/code\u003e, \u003ccode\u003e\u0026amp;\u003c/code\u003e, \u003ccode\u003e\"\u003c/code\u003e, and \u003ccode\u003e\u0027\u003c/code\u003e, but the CDATA path applies no neutralization at all. XML provides no character escaping mechanism inside a CDATA section, so the only correct way to represent a \u003ccode\u003e]]\u0026gt;\u003c/code\u003e substring is to split the section and rejoin it as \u003ccode\u003e]]]]\u0026gt;\u0026lt;![CDATA[\u0026gt;\u003c/code\u003e. Saxy neither performs that split nor rejects the substring.\u003c/p\u003e\n\u003cp\u003eAn application that renders untrusted text as CDATA, by building the node with \u003ccode\u003eSaxy.XML.cdata/1\u003c/code\u003e and serializing it with \u003ccode\u003eSaxy.encode!/2\u003c/code\u003e or \u003ccode\u003eSaxy.encode_to_iodata!/2\u003c/code\u003e, therefore lets an attacker terminate the section at a position of their choosing. Everything supplied after the \u003ccode\u003e]]\u0026gt;\u003c/code\u003e is emitted outside any CDATA context and is parsed as markup, so a single attacker-controlled text field becomes attacker-controlled document structure: arbitrary sibling elements and attributes that the application never built. Downstream consumers of the serialized document accept the injected markup as genuine. Services that generate SOAP envelopes, RSS or Atom feeds, sitemaps, SVG, or XML exports from user-supplied content can have trusted fields forged or overridden. Applications that never place untrusted text in a CDATA node are not affected.\u003c/p\u003e\n\u003cp\u003eThis issue affects saxy: from 0.7.0 before 1.7.0.\u003c/p\u003e"
},
{
"base64": false,
"type": "text/markdown",
"value": "XML injection in the Saxy XML encoder. `Saxy.Encoder.cdata/1` serializes a `{:cdata, characters}` node by wrapping the supplied characters between `\u003c![CDATA[` and `]]\u003e` and passing the payload through byte for byte. The encoder\u0027s character-data and attribute-value paths escape the XML metacharacters `\u003c`, `\u003e`, `\u0026`, `\"`, and `\u0027`, but the CDATA path applies no neutralization at all. XML provides no character escaping mechanism inside a CDATA section, so the only correct way to represent a `]]\u003e` substring is to split the section and rejoin it as `]]]]\u003e\u003c![CDATA[\u003e`. Saxy neither performs that split nor rejects the substring.\n\nAn application that renders untrusted text as CDATA, by building the node with `Saxy.XML.cdata/1` and serializing it with `Saxy.encode!/2` or `Saxy.encode_to_iodata!/2`, therefore lets an attacker terminate the section at a position of their choosing. Everything supplied after the `]]\u003e` is emitted outside any CDATA context and is parsed as markup, so a single attacker-controlled text field becomes attacker-controlled document structure: arbitrary sibling elements and attributes that the application never built. Downstream consumers of the serialized document accept the injected markup as genuine. Services that generate SOAP envelopes, RSS or Atom feeds, sitemaps, SVG, or XML exports from user-supplied content can have trusted fields forged or overridden. Applications that never place untrusted text in a CDATA node are not affected.\n\nThis issue affects saxy: from 0.7.0 before 1.7.0."
}
],
"value": "XML injection in the Saxy XML encoder. Saxy.Encoder.cdata/1 serializes a {:cdata, characters} node by wrapping the supplied characters between \u003c![CDATA[ and ]]\u003e and passing the payload through byte for byte. The encoder\u0027s character-data and attribute-value paths escape the XML metacharacters \u003c, \u003e, \u0026, \", and \u0027, but the CDATA path applies no neutralization at all. XML provides no character escaping mechanism inside a CDATA section, so the only correct way to represent a ]]\u003e substring is to split the section and rejoin it as ]]]]\u003e\u003c![CDATA[\u003e. Saxy neither performs that split nor rejects the substring.\n\nAn application that renders untrusted text as CDATA, by building the node with Saxy.XML.cdata/1 and serializing it with Saxy.encode!/2 or Saxy.encode_to_iodata!/2, therefore lets an attacker terminate the section at a position of their choosing. Everything supplied after the ]]\u003e is emitted outside any CDATA context and is parsed as markup, so a single attacker-controlled text field becomes attacker-controlled document structure: arbitrary sibling elements and attributes that the application never built. Downstream consumers of the serialized document accept the injected markup as genuine. Services that generate SOAP envelopes, RSS or Atom feeds, sitemaps, SVG, or XML exports from user-supplied content can have trusted fields forged or overridden. Applications that never place untrusted text in a CDATA node are not affected.\n\nThis issue affects saxy: from 0.7.0 before 1.7.0."
}
],
"impacts": [
{
"capecId": "CAPEC-250",
"descriptions": [
{
"lang": "en",
"value": "CAPEC-250 XML Injection"
}
]
}
],
"metrics": [
{
"cvssV4_0": {
"Automatable": "NOT_DEFINED",
"Recovery": "NOT_DEFINED",
"Safety": "NOT_DEFINED",
"attackComplexity": "LOW",
"attackRequirements": "PRESENT",
"attackVector": "LOCAL",
"baseScore": 2.1,
"baseSeverity": "LOW",
"privilegesRequired": "NONE",
"providerUrgency": "NOT_DEFINED",
"subAvailabilityImpact": "NONE",
"subConfidentialityImpact": "NONE",
"subIntegrityImpact": "LOW",
"userInteraction": "NONE",
"valueDensity": "NOT_DEFINED",
"vectorString": "CVSS:4.0/AV:L/AC:L/AT:P/PR:N/UI:N/VC:N/VI:L/VA:N/SC:N/SI:L/SA:N",
"version": "4.0",
"vulnAvailabilityImpact": "NONE",
"vulnConfidentialityImpact": "NONE",
"vulnIntegrityImpact": "LOW",
"vulnerabilityResponseEffort": "NOT_DEFINED"
},
"format": "CVSS",
"scenarios": [
{
"lang": "en",
"value": "GENERAL"
}
]
}
],
"problemTypes": [
{
"descriptions": [
{
"cweId": "CWE-91",
"description": "CWE-91 XML Injection",
"lang": "en",
"type": "CWE"
}
]
}
],
"providerMetadata": {
"dateUpdated": "2026-11-06T10:16:49.656Z",
"orgId": "6b3ad84c-e1a6-4bf7-a703-f496b71e49db",
"shortName": "EEF"
},
"references": [
{
"tags": [
"vendor-advisory",
"related"
],
"url": "https://github.com/qcam/saxy/security/advisories/GHSA-hq6g-j46w-wx72"
},
{
"name": "EEF CNA record for CVE-2026-66294",
"tags": [
"related"
],
"url": "https://cna.erlef.org/cves/CVE-2026-66294.html"
},
{
"name": "OSV record EEF-CVE-2026-66294",
"tags": [
"related"
],
"url": "https://osv.dev/vulnerability/EEF-CVE-2026-66294"
},
{
"name": "Introducing commit 7266632 in qcam/saxy",
"tags": [
"related"
],
"url": "https://github.com/qcam/saxy/commit/7266632e9407842f28ffac254c56dc41210a5690"
},
{
"name": "Fix commit 71d99fa in qcam/saxy",
"tags": [
"patch"
],
"url": "https://github.com/qcam/saxy/commit/71d99fae12f1d6596f5edb57b71f26ba854f4d33"
}
],
"source": {
"discovery": "EXTERNAL"
},
"title": "XML injection in Saxy via unescaped CDATA section terminator",
"workarounds": [
{
"lang": "en",
"supportingMedia": [
{
"base64": false,
"type": "text/html",
"value": "\u003cp\u003eRender untrusted text as ordinary character data instead of CDATA, by passing a plain string or \u003ccode\u003eSaxy.XML.characters/1\u003c/code\u003e to the element builder. That encoder path escapes the XML metacharacters and preserves the same text content.\u003c/p\u003e\n\u003cp\u003eIf a CDATA section is required, split the terminator in application code before building the node: replace every occurrence of \u003ccode\u003e]]\u0026gt;\u003c/code\u003e in the untrusted text with \u003ccode\u003e]]]]\u0026gt;\u0026lt;![CDATA[\u0026gt;\u003c/code\u003e and pass the result to \u003ccode\u003eSaxy.XML.cdata/1\u003c/code\u003e. Once the encoder wraps that payload, the output is the standard split encoding.\u003c/p\u003e"
},
{
"base64": false,
"type": "text/markdown",
"value": "Render untrusted text as ordinary character data instead of CDATA, by passing a plain string or `Saxy.XML.characters/1` to the element builder. That encoder path escapes the XML metacharacters and preserves the same text content.\n\nIf a CDATA section is required, split the terminator in application code before building the node: replace every occurrence of `]]\u003e` in the untrusted text with `]]]]\u003e\u003c![CDATA[\u003e` and pass the result to `Saxy.XML.cdata/1`. Once the encoder wraps that payload, the output is the standard split encoding."
}
],
"value": "Render untrusted text as ordinary character data instead of CDATA, by passing a plain string or Saxy.XML.characters/1 to the element builder. That encoder path escapes the XML metacharacters and preserves the same text content.\n\nIf a CDATA section is required, split the terminator in application code before building the node: replace every occurrence of ]]\u003e in the untrusted text with ]]]]\u003e\u003c![CDATA[\u003e and pass the result to Saxy.XML.cdata/1. Once the encoder wraps that payload, the output is the standard split encoding."
}
]
}
},
"cveMetadata": {
"assignerOrgId": "6b3ad84c-e1a6-4bf7-a703-f496b71e49db",
"assignerShortName": "EEF",
"cveId": "CVE-2026-66294",
"datePublished": "2026-11-06T10:16:49.656Z",
"dateReserved": "2026-07-24T14:10:01.114Z",
"dateUpdated": "2026-11-10T17:43:39.205Z",
"state": "PUBLISHED"
},
"dataType": "CVE_RECORD",
"dataVersion": "5.2"
}
CVE-2026-66295 (GCVE-0-2026-66295)
Vulnerability from cvelistv5 – Published: 2026-11-06 10:16 – Updated: 2026-11-10 17:43
VLAI
EPSS
VEX
Title
Processing instruction target and content emitted unescaped in Saxy encoder allows XML injection
Summary
Saxy's XML encoder writes the target and the content of a processing instruction into the output document verbatim, without validation or escaping. The encoder builds the construct by concatenating both fields between <? and ?>, so neither field is checked against the constraints the XML specification places on it.
Because ?> terminates a processing instruction, a target or content value containing that sequence closes the construct at a position the attacker chooses. Everything the attacker writes after it is emitted as ordinary markup at document level, which allows arbitrary elements, attributes, and further processing instructions to be injected into the serialized document. Appending a matching <? keeps the encoder's own terminator well-formed, so the result still parses and the injection is silent rather than a visible corruption. The target is additionally unconstrained by the XML Name production, which permits injection before the remainder of the processing instruction.
The result is XML injection into a serialized document: the tree the application asks Saxy to encode and the tree a downstream parser reads back are no longer the same. Character data, attribute values, and comments are all escaped by the encoder, so processing instructions are the one construct that passes user bytes through untouched. Since Saxy.XML.processing_instruction/2 is a documented public builder, an application inherits the flaw without misusing the API. The consequences depend on what consumes the XML: forged records in a feed or export read by another system, injected stylesheet or rendering directives, or active content where the output is served to a browser as XML or SVG.
This issue affects saxy: from 0.7.0 before 1.7.0.
Severity
SSVC
Exploitation: none
Automatable: no
Technical Impact: partial
CISA Coordinator · CISA-ADP (v2.0.3)
Decision recorded 2026-11-10 17:42 UTC
CWE
- CWE-91 - XML Injection
Assigner
References
5 references
| URL | Tags |
|---|---|
| https://github.com/qcam/saxy/security/advisories/… | vendor-advisoryrelated |
| https://cna.erlef.org/cves/CVE-2026-66295.html | related |
| https://osv.dev/vulnerability/EEF-CVE-2026-66295 | related |
| https://github.com/qcam/saxy/commit/7266632e94078… | related |
| https://github.com/qcam/saxy/commit/4aeb80e2acc31… | patch |
Impacted products
{
"containers": {
"adp": [
{
"metrics": [
{
"other": {
"content": {
"id": "CVE-2026-66295",
"options": [
{
"Exploitation": "none"
},
{
"Automatable": "no"
},
{
"Technical Impact": "partial"
}
],
"role": "CISA Coordinator",
"timestamp": "2026-11-10T17:42:52.953846Z",
"version": "2.0.3"
},
"type": "ssvc"
}
}
],
"providerMetadata": {
"dateUpdated": "2026-11-10T17:43:03.747Z",
"orgId": "134c704f-9b21-4f2e-91b3-4a467353bcc0",
"shortName": "CISA-ADP"
},
"title": "CISA ADP Vulnrichment"
}
],
"cna": {
"affected": [
{
"collectionURL": "https://repo.hex.pm",
"cpes": [
"cpe:2.3:a:qcam:saxy:*:*:*:*:*:*:*:*"
],
"defaultStatus": "unaffected",
"modules": [
"\u0027Elixir.Saxy\u0027",
"\u0027Elixir.Saxy.Encoder\u0027",
"\u0027Elixir.Saxy.XML\u0027"
],
"packageName": "saxy",
"packageURL": "pkg:hex/saxy",
"product": "saxy",
"programFiles": [
"lib/saxy.ex",
"lib/saxy/encoder.ex",
"lib/saxy/xml.ex"
],
"programRoutines": [
{
"name": "\u0027Elixir.Saxy\u0027:encode!/2"
},
{
"name": "\u0027Elixir.Saxy\u0027:encode_to_iodata!/2"
},
{
"name": "\u0027Elixir.Saxy.Encoder\u0027:encode_to_iodata/2"
},
{
"name": "\u0027Elixir.Saxy.XML\u0027:processing_instruction/2"
}
],
"repo": "https://github.com/qcam/saxy",
"vendor": "qcam",
"versions": [
{
"lessThan": "1.7.0",
"status": "affected",
"version": "0.7.0",
"versionType": "semver"
}
]
},
{
"collectionURL": "https://github.com",
"cpes": [
"cpe:2.3:a:qcam:saxy:*:*:*:*:*:*:*:*"
],
"defaultStatus": "unaffected",
"modules": [
"\u0027Elixir.Saxy\u0027",
"\u0027Elixir.Saxy.Encoder\u0027",
"\u0027Elixir.Saxy.XML\u0027"
],
"packageName": "qcam/saxy",
"packageURL": "pkg:github/qcam/saxy",
"product": "saxy",
"programFiles": [
"lib/saxy.ex",
"lib/saxy/encoder.ex",
"lib/saxy/xml.ex"
],
"programRoutines": [
{
"name": "\u0027Elixir.Saxy\u0027:encode!/2"
},
{
"name": "\u0027Elixir.Saxy\u0027:encode_to_iodata!/2"
},
{
"name": "\u0027Elixir.Saxy.Encoder\u0027:encode_to_iodata/2"
},
{
"name": "\u0027Elixir.Saxy.XML\u0027:processing_instruction/2"
}
],
"repo": "https://github.com/qcam/saxy",
"vendor": "qcam",
"versions": [
{
"lessThan": "4aeb80e2acc318f57bd7453ad2eafd59101a90e0",
"status": "affected",
"version": "7266632e9407842f28ffac254c56dc41210a5690",
"versionType": "git"
}
]
}
],
"configurations": [
{
"lang": "en",
"supportingMedia": [
{
"base64": false,
"type": "text/html",
"value": "\u003cp\u003eThe application must serialize a document containing a processing instruction whose target or content is derived from untrusted input, by passing a \u003ccode\u003e{:processing_instruction, name, content}\u003c/code\u003e node (typically built with \u003ccode\u003eSaxy.XML.processing_instruction/2\u003c/code\u003e) to \u003ccode\u003eSaxy.encode!/2\u003c/code\u003e or \u003ccode\u003eSaxy.encode_to_iodata!/2\u003c/code\u003e. Applications that never place dynamic data in processing instructions are not affected.\u003c/p\u003e"
},
{
"base64": false,
"type": "text/markdown",
"value": "The application must serialize a document containing a processing instruction whose target or content is derived from untrusted input, by passing a `{:processing_instruction, name, content}` node (typically built with `Saxy.XML.processing_instruction/2`) to `Saxy.encode!/2` or `Saxy.encode_to_iodata!/2`. Applications that never place dynamic data in processing instructions are not affected."
}
],
"value": "The application must serialize a document containing a processing instruction whose target or content is derived from untrusted input, by passing a {:processing_instruction, name, content} node (typically built with Saxy.XML.processing_instruction/2) to Saxy.encode!/2 or Saxy.encode_to_iodata!/2. Applications that never place dynamic data in processing instructions are not affected."
}
],
"cpeApplicability": [
{
"nodes": [
{
"cpeMatch": [
{
"criteria": "cpe:2.3:a:qcam:saxy:*:*:*:*:*:*:*:*",
"versionEndExcluding": "1.7.0",
"versionStartIncluding": "0.7.0",
"vulnerable": true
}
],
"negate": false,
"operator": "OR"
}
],
"operator": "AND"
}
],
"credits": [
{
"lang": "en",
"type": "finder",
"value": "Peter Ullrich"
},
{
"lang": "en",
"type": "reporter",
"value": "Peter Ullrich"
},
{
"lang": "en",
"type": "remediation developer",
"value": "Peter Ullrich"
},
{
"lang": "en",
"type": "remediation reviewer",
"value": "C\u1ea9m Hu\u1ef3nh"
},
{
"lang": "en",
"type": "analyst",
"value": "Jonatan M\u00e4nnchen / EEF"
},
{
"lang": "en",
"type": "coordinator",
"value": "Peter Ullrich"
}
],
"descriptions": [
{
"lang": "en",
"supportingMedia": [
{
"base64": false,
"type": "text/html",
"value": "\u003cp\u003eSaxy\u0027s XML encoder writes the target and the content of a processing instruction into the output document verbatim, without validation or escaping. The encoder builds the construct by concatenating both fields between \u003ccode\u003e\u0026lt;?\u003c/code\u003e and \u003ccode\u003e?\u0026gt;\u003c/code\u003e, so neither field is checked against the constraints the XML specification places on it.\u003c/p\u003e\n\u003cp\u003eBecause \u003ccode\u003e?\u0026gt;\u003c/code\u003e terminates a processing instruction, a target or content value containing that sequence closes the construct at a position the attacker chooses. Everything the attacker writes after it is emitted as ordinary markup at document level, which allows arbitrary elements, attributes, and further processing instructions to be injected into the serialized document. Appending a matching \u003ccode\u003e\u0026lt;?\u003c/code\u003e keeps the encoder\u0027s own terminator well-formed, so the result still parses and the injection is silent rather than a visible corruption. The target is additionally unconstrained by the XML \u003ccode\u003eName\u003c/code\u003e production, which permits injection before the remainder of the processing instruction.\u003c/p\u003e\n\u003cp\u003eThe result is XML injection into a serialized document: the tree the application asks Saxy to encode and the tree a downstream parser reads back are no longer the same. Character data, attribute values, and comments are all escaped by the encoder, so processing instructions are the one construct that passes user bytes through untouched. Since \u003ccode\u003eSaxy.XML.processing_instruction/2\u003c/code\u003e is a documented public builder, an application inherits the flaw without misusing the API. The consequences depend on what consumes the XML: forged records in a feed or export read by another system, injected stylesheet or rendering directives, or active content where the output is served to a browser as XML or SVG.\u003c/p\u003e\n\u003cp\u003eThis issue affects saxy: from 0.7.0 before 1.7.0.\u003c/p\u003e"
},
{
"base64": false,
"type": "text/markdown",
"value": "Saxy\u0027s XML encoder writes the target and the content of a processing instruction into the output document verbatim, without validation or escaping. The encoder builds the construct by concatenating both fields between `\u003c?` and `?\u003e`, so neither field is checked against the constraints the XML specification places on it.\n\nBecause `?\u003e` terminates a processing instruction, a target or content value containing that sequence closes the construct at a position the attacker chooses. Everything the attacker writes after it is emitted as ordinary markup at document level, which allows arbitrary elements, attributes, and further processing instructions to be injected into the serialized document. Appending a matching `\u003c?` keeps the encoder\u0027s own terminator well-formed, so the result still parses and the injection is silent rather than a visible corruption. The target is additionally unconstrained by the XML `Name` production, which permits injection before the remainder of the processing instruction.\n\nThe result is XML injection into a serialized document: the tree the application asks Saxy to encode and the tree a downstream parser reads back are no longer the same. Character data, attribute values, and comments are all escaped by the encoder, so processing instructions are the one construct that passes user bytes through untouched. Since `Saxy.XML.processing_instruction/2` is a documented public builder, an application inherits the flaw without misusing the API. The consequences depend on what consumes the XML: forged records in a feed or export read by another system, injected stylesheet or rendering directives, or active content where the output is served to a browser as XML or SVG.\n\nThis issue affects saxy: from 0.7.0 before 1.7.0."
}
],
"value": "Saxy\u0027s XML encoder writes the target and the content of a processing instruction into the output document verbatim, without validation or escaping. The encoder builds the construct by concatenating both fields between \u003c? and ?\u003e, so neither field is checked against the constraints the XML specification places on it.\n\nBecause ?\u003e terminates a processing instruction, a target or content value containing that sequence closes the construct at a position the attacker chooses. Everything the attacker writes after it is emitted as ordinary markup at document level, which allows arbitrary elements, attributes, and further processing instructions to be injected into the serialized document. Appending a matching \u003c? keeps the encoder\u0027s own terminator well-formed, so the result still parses and the injection is silent rather than a visible corruption. The target is additionally unconstrained by the XML Name production, which permits injection before the remainder of the processing instruction.\n\nThe result is XML injection into a serialized document: the tree the application asks Saxy to encode and the tree a downstream parser reads back are no longer the same. Character data, attribute values, and comments are all escaped by the encoder, so processing instructions are the one construct that passes user bytes through untouched. Since Saxy.XML.processing_instruction/2 is a documented public builder, an application inherits the flaw without misusing the API. The consequences depend on what consumes the XML: forged records in a feed or export read by another system, injected stylesheet or rendering directives, or active content where the output is served to a browser as XML or SVG.\n\nThis issue affects saxy: from 0.7.0 before 1.7.0."
}
],
"impacts": [
{
"capecId": "CAPEC-250",
"descriptions": [
{
"lang": "en",
"value": "CAPEC-250 XML Injection"
}
]
}
],
"metrics": [
{
"cvssV4_0": {
"Automatable": "NOT_DEFINED",
"Recovery": "NOT_DEFINED",
"Safety": "NOT_DEFINED",
"attackComplexity": "LOW",
"attackRequirements": "PRESENT",
"attackVector": "LOCAL",
"baseScore": 2.1,
"baseSeverity": "LOW",
"privilegesRequired": "NONE",
"providerUrgency": "NOT_DEFINED",
"subAvailabilityImpact": "NONE",
"subConfidentialityImpact": "NONE",
"subIntegrityImpact": "LOW",
"userInteraction": "NONE",
"valueDensity": "NOT_DEFINED",
"vectorString": "CVSS:4.0/AV:L/AC:L/AT:P/PR:N/UI:N/VC:N/VI:L/VA:N/SC:N/SI:L/SA:N",
"version": "4.0",
"vulnAvailabilityImpact": "NONE",
"vulnConfidentialityImpact": "NONE",
"vulnIntegrityImpact": "LOW",
"vulnerabilityResponseEffort": "NOT_DEFINED"
},
"format": "CVSS",
"scenarios": [
{
"lang": "en",
"value": "GENERAL"
}
]
}
],
"problemTypes": [
{
"descriptions": [
{
"cweId": "CWE-91",
"description": "CWE-91 XML Injection",
"lang": "en",
"type": "CWE"
}
]
}
],
"providerMetadata": {
"dateUpdated": "2026-11-06T10:16:45.577Z",
"orgId": "6b3ad84c-e1a6-4bf7-a703-f496b71e49db",
"shortName": "EEF"
},
"references": [
{
"tags": [
"vendor-advisory",
"related"
],
"url": "https://github.com/qcam/saxy/security/advisories/GHSA-f9r9-vrxq-g566"
},
{
"name": "EEF CNA record for CVE-2026-66295",
"tags": [
"related"
],
"url": "https://cna.erlef.org/cves/CVE-2026-66295.html"
},
{
"name": "OSV record EEF-CVE-2026-66295",
"tags": [
"related"
],
"url": "https://osv.dev/vulnerability/EEF-CVE-2026-66295"
},
{
"name": "Introducing commit 7266632 in qcam/saxy",
"tags": [
"related"
],
"url": "https://github.com/qcam/saxy/commit/7266632e9407842f28ffac254c56dc41210a5690"
},
{
"name": "Fix commit 4aeb80e in qcam/saxy",
"tags": [
"patch"
],
"url": "https://github.com/qcam/saxy/commit/4aeb80e2acc318f57bd7453ad2eafd59101a90e0"
}
],
"source": {
"discovery": "EXTERNAL"
},
"title": "Processing instruction target and content emitted unescaped in Saxy encoder allows XML injection",
"workarounds": [
{
"lang": "en",
"supportingMedia": [
{
"base64": false,
"type": "text/html",
"value": "\u003cp\u003eDo not place untrusted data in processing instructions. Where a dynamic value is unavoidable, validate it before building the node: reject any content containing \u003ccode\u003e?\u0026gt;\u003c/code\u003e, and reject any target that does not match the XML \u003ccode\u003eName\u003c/code\u003e production or that equals \u003ccode\u003exml\u003c/code\u003e case-insensitively. Alternatively, carry the value in element content or an attribute instead, both of which the encoder escapes.\u003c/p\u003e"
},
{
"base64": false,
"type": "text/markdown",
"value": "Do not place untrusted data in processing instructions. Where a dynamic value is unavoidable, validate it before building the node: reject any content containing `?\u003e`, and reject any target that does not match the XML `Name` production or that equals `xml` case-insensitively. Alternatively, carry the value in element content or an attribute instead, both of which the encoder escapes."
}
],
"value": "Do not place untrusted data in processing instructions. Where a dynamic value is unavoidable, validate it before building the node: reject any content containing ?\u003e, and reject any target that does not match the XML Name production or that equals xml case-insensitively. Alternatively, carry the value in element content or an attribute instead, both of which the encoder escapes."
}
]
}
},
"cveMetadata": {
"assignerOrgId": "6b3ad84c-e1a6-4bf7-a703-f496b71e49db",
"assignerShortName": "EEF",
"cveId": "CVE-2026-66295",
"datePublished": "2026-11-06T10:16:45.577Z",
"dateReserved": "2026-07-24T14:10:01.114Z",
"dateUpdated": "2026-11-10T17:43:03.747Z",
"state": "PUBLISHED"
},
"dataType": "CVE_RECORD",
"dataVersion": "5.2"
}
CVE-2026-66293 (GCVE-0-2026-66293)
Vulnerability from cvelistv5 – Published: 2026-11-06 10:16 – Updated: 2026-11-10 17:42
VLAI
EPSS
VEX
Title
XML comment injection in Saxy encoder via unescaped double hyphen
Summary
XML injection in the encoder of Saxy. Saxy.Encoder does not neutralize the -- sequence in XML comment content: escape_comment/2 returns the comment binary unchanged in every clause and only appends a space when the comment ends in a single -, so neither an interior -- nor a full --> is escaped or rejected before the encoder wraps the content in the <!-- and --> delimiters.
When an application serializes attacker-controlled text as a comment, through Saxy.XML.comment/1 or a raw {:comment, text} simple form child, the attacker can close the comment early with --> and inject arbitrary sibling markup into the serialized document. A downstream parser then sees elements, attributes, and text that the application never built. Where the document is rendered as HTML or SVG, the injected markup executes as stored cross-site scripting; where it is consumed by another XML processor, the attacker can forge data that the producing application never emitted. Documents that Saxy only parses are not a vector.
This issue affects saxy: from 0.7.0 before 1.7.0.
Severity
SSVC
Exploitation: none
Automatable: no
Technical Impact: partial
CISA Coordinator · CISA-ADP (v2.0.3)
Decision recorded 2026-11-10 17:42 UTC
CWE
- CWE-91 - XML Injection
Assigner
References
5 references
| URL | Tags |
|---|---|
| https://github.com/qcam/saxy/security/advisories/… | vendor-advisoryrelated |
| https://cna.erlef.org/cves/CVE-2026-66293.html | related |
| https://osv.dev/vulnerability/EEF-CVE-2026-66293 | related |
| https://github.com/qcam/saxy/commit/7266632e94078… | related |
| https://github.com/qcam/saxy/commit/6d12ee12b9e32… | patch |
Impacted products
{
"containers": {
"adp": [
{
"metrics": [
{
"other": {
"content": {
"id": "CVE-2026-66293",
"options": [
{
"Exploitation": "none"
},
{
"Automatable": "no"
},
{
"Technical Impact": "partial"
}
],
"role": "CISA Coordinator",
"timestamp": "2026-11-10T17:42:07.677044Z",
"version": "2.0.3"
},
"type": "ssvc"
}
}
],
"providerMetadata": {
"dateUpdated": "2026-11-10T17:42:24.114Z",
"orgId": "134c704f-9b21-4f2e-91b3-4a467353bcc0",
"shortName": "CISA-ADP"
},
"title": "CISA ADP Vulnrichment"
}
],
"cna": {
"affected": [
{
"collectionURL": "https://repo.hex.pm",
"cpes": [
"cpe:2.3:a:qcam:saxy:*:*:*:*:*:*:*:*"
],
"defaultStatus": "unaffected",
"modules": [
"\u0027Elixir.Saxy\u0027",
"\u0027Elixir.Saxy.Encoder\u0027",
"\u0027Elixir.Saxy.XML\u0027"
],
"packageName": "saxy",
"packageURL": "pkg:hex/saxy",
"product": "saxy",
"programFiles": [
"lib/saxy.ex",
"lib/saxy/encoder.ex",
"lib/saxy/xml.ex"
],
"programRoutines": [
{
"name": "\u0027Elixir.Saxy\u0027:encode!/2"
},
{
"name": "\u0027Elixir.Saxy\u0027:encode_to_iodata!/2"
},
{
"name": "\u0027Elixir.Saxy.XML\u0027:comment/1"
}
],
"repo": "https://github.com/qcam/saxy",
"vendor": "qcam",
"versions": [
{
"lessThan": "1.7.0",
"status": "affected",
"version": "0.7.0",
"versionType": "semver"
}
]
},
{
"collectionURL": "https://github.com",
"cpes": [
"cpe:2.3:a:qcam:saxy:*:*:*:*:*:*:*:*"
],
"defaultStatus": "unaffected",
"modules": [
"\u0027Elixir.Saxy\u0027",
"\u0027Elixir.Saxy.Encoder\u0027",
"\u0027Elixir.Saxy.XML\u0027"
],
"packageName": "qcam/saxy",
"packageURL": "pkg:github/qcam/saxy",
"product": "saxy",
"programFiles": [
"lib/saxy.ex",
"lib/saxy/encoder.ex",
"lib/saxy/xml.ex"
],
"programRoutines": [
{
"name": "\u0027Elixir.Saxy\u0027:encode!/2"
},
{
"name": "\u0027Elixir.Saxy\u0027:encode_to_iodata!/2"
},
{
"name": "\u0027Elixir.Saxy.XML\u0027:comment/1"
}
],
"repo": "https://github.com/qcam/saxy",
"vendor": "qcam",
"versions": [
{
"lessThan": "6d12ee12b9e32e86ef598b3f3cafccd1840b2f41",
"status": "affected",
"version": "7266632e9407842f28ffac254c56dc41210a5690",
"versionType": "git"
}
]
}
],
"configurations": [
{
"lang": "en",
"supportingMedia": [
{
"base64": false,
"type": "text/html",
"value": "\u003cp\u003eOnly applications that place untrusted text into an XML comment node are affected. The untrusted value must reach \u003ccode\u003eSaxy.XML.comment/1\u003c/code\u003e or a raw \u003ccode\u003e{:comment, text}\u003c/code\u003e simple form child that is then serialized with \u003ccode\u003eSaxy.encode!/2\u003c/code\u003e or \u003ccode\u003eSaxy.encode_to_iodata!/2\u003c/code\u003e. Documents that are only parsed by Saxy never reach the encoder and are not a vector.\u003c/p\u003e"
},
{
"base64": false,
"type": "text/markdown",
"value": "Only applications that place untrusted text into an XML comment node are affected. The untrusted value must reach `Saxy.XML.comment/1` or a raw `{:comment, text}` simple form child that is then serialized with `Saxy.encode!/2` or `Saxy.encode_to_iodata!/2`. Documents that are only parsed by Saxy never reach the encoder and are not a vector."
}
],
"value": "Only applications that place untrusted text into an XML comment node are affected. The untrusted value must reach Saxy.XML.comment/1 or a raw {:comment, text} simple form child that is then serialized with Saxy.encode!/2 or Saxy.encode_to_iodata!/2. Documents that are only parsed by Saxy never reach the encoder and are not a vector."
}
],
"cpeApplicability": [
{
"nodes": [
{
"cpeMatch": [
{
"criteria": "cpe:2.3:a:qcam:saxy:*:*:*:*:*:*:*:*",
"versionEndExcluding": "1.7.0",
"versionStartIncluding": "0.7.0",
"vulnerable": true
}
],
"negate": false,
"operator": "OR"
}
],
"operator": "AND"
}
],
"credits": [
{
"lang": "en",
"type": "finder",
"value": "Peter Ullrich"
},
{
"lang": "en",
"type": "reporter",
"value": "Peter Ullrich"
},
{
"lang": "en",
"type": "remediation developer",
"value": "Peter Ullrich"
},
{
"lang": "en",
"type": "remediation reviewer",
"value": "C\u1ea9m Hu\u1ef3nh"
},
{
"lang": "en",
"type": "analyst",
"value": "Jonatan M\u00e4nnchen / EEF"
},
{
"lang": "en",
"type": "coordinator",
"value": "Peter Ullrich"
}
],
"descriptions": [
{
"lang": "en",
"supportingMedia": [
{
"base64": false,
"type": "text/html",
"value": "\u003cp\u003eXML injection in the encoder of Saxy. \u003ccode\u003eSaxy.Encoder\u003c/code\u003e does not neutralize the \u003ccode\u003e--\u003c/code\u003e sequence in XML comment content: \u003ccode\u003eescape_comment/2\u003c/code\u003e returns the comment binary unchanged in every clause and only appends a space when the comment ends in a single \u003ccode\u003e-\u003c/code\u003e, so neither an interior \u003ccode\u003e--\u003c/code\u003e nor a full \u003ccode\u003e--\u0026gt;\u003c/code\u003e is escaped or rejected before the encoder wraps the content in the \u003ccode\u003e\u0026lt;!--\u003c/code\u003e and \u003ccode\u003e--\u0026gt;\u003c/code\u003e delimiters.\u003c/p\u003e\n\u003cp\u003eWhen an application serializes attacker-controlled text as a comment, through \u003ccode\u003eSaxy.XML.comment/1\u003c/code\u003e or a raw \u003ccode\u003e{:comment, text}\u003c/code\u003e simple form child, the attacker can close the comment early with \u003ccode\u003e--\u0026gt;\u003c/code\u003e and inject arbitrary sibling markup into the serialized document. A downstream parser then sees elements, attributes, and text that the application never built. Where the document is rendered as HTML or SVG, the injected markup executes as stored cross-site scripting; where it is consumed by another XML processor, the attacker can forge data that the producing application never emitted. Documents that Saxy only parses are not a vector.\u003c/p\u003e\n\u003cp\u003eThis issue affects saxy: from 0.7.0 before 1.7.0.\u003c/p\u003e"
},
{
"base64": false,
"type": "text/markdown",
"value": "XML injection in the encoder of Saxy. `Saxy.Encoder` does not neutralize the `--` sequence in XML comment content: `escape_comment/2` returns the comment binary unchanged in every clause and only appends a space when the comment ends in a single `-`, so neither an interior `--` nor a full `--\u003e` is escaped or rejected before the encoder wraps the content in the `\u003c!--` and `--\u003e` delimiters.\n\nWhen an application serializes attacker-controlled text as a comment, through `Saxy.XML.comment/1` or a raw `{:comment, text}` simple form child, the attacker can close the comment early with `--\u003e` and inject arbitrary sibling markup into the serialized document. A downstream parser then sees elements, attributes, and text that the application never built. Where the document is rendered as HTML or SVG, the injected markup executes as stored cross-site scripting; where it is consumed by another XML processor, the attacker can forge data that the producing application never emitted. Documents that Saxy only parses are not a vector.\n\nThis issue affects saxy: from 0.7.0 before 1.7.0."
}
],
"value": "XML injection in the encoder of Saxy. Saxy.Encoder does not neutralize the -- sequence in XML comment content: escape_comment/2 returns the comment binary unchanged in every clause and only appends a space when the comment ends in a single -, so neither an interior -- nor a full --\u003e is escaped or rejected before the encoder wraps the content in the \u003c!-- and --\u003e delimiters.\n\nWhen an application serializes attacker-controlled text as a comment, through Saxy.XML.comment/1 or a raw {:comment, text} simple form child, the attacker can close the comment early with --\u003e and inject arbitrary sibling markup into the serialized document. A downstream parser then sees elements, attributes, and text that the application never built. Where the document is rendered as HTML or SVG, the injected markup executes as stored cross-site scripting; where it is consumed by another XML processor, the attacker can forge data that the producing application never emitted. Documents that Saxy only parses are not a vector.\n\nThis issue affects saxy: from 0.7.0 before 1.7.0."
}
],
"impacts": [
{
"capecId": "CAPEC-250",
"descriptions": [
{
"lang": "en",
"value": "CAPEC-250 XML Injection"
}
]
}
],
"metrics": [
{
"cvssV4_0": {
"Automatable": "NOT_DEFINED",
"Recovery": "NOT_DEFINED",
"Safety": "NOT_DEFINED",
"attackComplexity": "LOW",
"attackRequirements": "PRESENT",
"attackVector": "LOCAL",
"baseScore": 2.1,
"baseSeverity": "LOW",
"privilegesRequired": "NONE",
"providerUrgency": "NOT_DEFINED",
"subAvailabilityImpact": "NONE",
"subConfidentialityImpact": "LOW",
"subIntegrityImpact": "LOW",
"userInteraction": "NONE",
"valueDensity": "NOT_DEFINED",
"vectorString": "CVSS:4.0/AV:L/AC:L/AT:P/PR:N/UI:N/VC:N/VI:L/VA:N/SC:L/SI:L/SA:N",
"version": "4.0",
"vulnAvailabilityImpact": "NONE",
"vulnConfidentialityImpact": "NONE",
"vulnIntegrityImpact": "LOW",
"vulnerabilityResponseEffort": "NOT_DEFINED"
},
"format": "CVSS",
"scenarios": [
{
"lang": "en",
"value": "GENERAL"
}
]
}
],
"problemTypes": [
{
"descriptions": [
{
"cweId": "CWE-91",
"description": "CWE-91 XML Injection",
"lang": "en",
"type": "CWE"
}
]
}
],
"providerMetadata": {
"dateUpdated": "2026-11-06T10:16:42.053Z",
"orgId": "6b3ad84c-e1a6-4bf7-a703-f496b71e49db",
"shortName": "EEF"
},
"references": [
{
"tags": [
"vendor-advisory",
"related"
],
"url": "https://github.com/qcam/saxy/security/advisories/GHSA-mm3m-xwmf-v698"
},
{
"name": "EEF CNA record for CVE-2026-66293",
"tags": [
"related"
],
"url": "https://cna.erlef.org/cves/CVE-2026-66293.html"
},
{
"name": "OSV record EEF-CVE-2026-66293",
"tags": [
"related"
],
"url": "https://osv.dev/vulnerability/EEF-CVE-2026-66293"
},
{
"name": "Introducing commit 7266632 in qcam/saxy",
"tags": [
"related"
],
"url": "https://github.com/qcam/saxy/commit/7266632e9407842f28ffac254c56dc41210a5690"
},
{
"name": "Fix commit 6d12ee1 in qcam/saxy",
"tags": [
"patch"
],
"url": "https://github.com/qcam/saxy/commit/6d12ee12b9e32e86ef598b3f3cafccd1840b2f41"
}
],
"source": {
"discovery": "EXTERNAL"
},
"title": "XML comment injection in Saxy encoder via unescaped double hyphen",
"workarounds": [
{
"lang": "en",
"supportingMedia": [
{
"base64": false,
"type": "text/html",
"value": "\u003cp\u003eReject or rewrite the \u003ccode\u003e--\u003c/code\u003e sequence in untrusted text before passing it to \u003ccode\u003eSaxy.XML.comment/1\u003c/code\u003e, for example by replacing \u003ccode\u003e--\u003c/code\u003e with \u003ccode\u003e- -\u003c/code\u003e.\u003c/p\u003e\n\u003cp\u003eAlternatively, stop emitting untrusted data as XML comments. Comments carry no semantic payload for consumers, so the text can usually be moved into an element or a CDATA section instead.\u003c/p\u003e"
},
{
"base64": false,
"type": "text/markdown",
"value": "Reject or rewrite the `--` sequence in untrusted text before passing it to `Saxy.XML.comment/1`, for example by replacing `--` with `- -`.\n\nAlternatively, stop emitting untrusted data as XML comments. Comments carry no semantic payload for consumers, so the text can usually be moved into an element or a CDATA section instead."
}
],
"value": "Reject or rewrite the -- sequence in untrusted text before passing it to Saxy.XML.comment/1, for example by replacing -- with - -.\n\nAlternatively, stop emitting untrusted data as XML comments. Comments carry no semantic payload for consumers, so the text can usually be moved into an element or a CDATA section instead."
}
]
}
},
"cveMetadata": {
"assignerOrgId": "6b3ad84c-e1a6-4bf7-a703-f496b71e49db",
"assignerShortName": "EEF",
"cveId": "CVE-2026-66293",
"datePublished": "2026-11-06T10:16:42.053Z",
"dateReserved": "2026-07-24T14:10:01.114Z",
"dateUpdated": "2026-11-10T17:42:24.114Z",
"state": "PUBLISHED"
},
"dataType": "CVE_RECORD",
"dataVersion": "5.2"
}
CVE-2026-119434 (GCVE-0-2026-119434)
Vulnerability from cvelistv5 – Published: 2026-11-05 20:15 – Updated: 2026-11-05 20:45
VLAI
EPSS
VEX
Title
Decompression bomb in ezstd:decompress_using_ddict/2 allows BEAM memory exhaustion via unbounded output buffer
Summary
Improper Handling of Highly Compressed Data (Data Amplification) vulnerability in silviucpp ezstd allows an attacker who supplies a zstd frame to ezstd:decompress_using_ddict/2 to exhaust memory and terminate the Erlang VM via a small frame that declares a very large content size.
zstd_nif_decompress_using_ddict in c_src/ezstd_nif.cc reads the frame's declared content size with ZSTD_getFrameContentSize and passes it directly to enif_make_new_binary, without the 1 GiB MAX_BUFFER_SIZE check that ezstd:decompress/1 applies before allocating. A valid 65,550-byte frame of RLE blocks declaring 2 GiB is rejected by ezstd:decompress/1 but is fully allocated and decompressed by the dictionary path. The allocation happens inside a NIF, so when it exceeds the memory available to the VM the operating system kills the whole node rather than the calling process.
This issue affects ezstd: from 1.0.5 before 1.2.5.
Severity
6.2 (Medium)
SSVC
Exploitation: none
Automatable: no
Technical Impact: partial
CISA Coordinator · CISA-ADP (v2.0.3)
Decision recorded 2026-11-05 20:45 UTC
CWE
- CWE-409 - Improper Handling of Highly Compressed Data (Data Amplification)
Assigner
References
5 references
| URL | Tags |
|---|---|
| https://github.com/silviucpp/ezstd/releases/tag/v1.2.5 | release-notesrelated |
| https://cna.erlef.org/cves/CVE-2026-119434.html | relatedthird-party-advisory |
| https://osv.dev/vulnerability/EEF-CVE-2026-119434 | related |
| https://github.com/silviucpp/ezstd/commit/6730128… | related |
| https://github.com/silviucpp/ezstd/commit/e34f4a4… | patch |
Impacted products
{
"containers": {
"adp": [
{
"metrics": [
{
"cvssV3_1": {
"attackComplexity": "LOW",
"attackVector": "LOCAL",
"availabilityImpact": "HIGH",
"baseScore": 6.2,
"baseSeverity": "MEDIUM",
"confidentialityImpact": "NONE",
"integrityImpact": "NONE",
"privilegesRequired": "NONE",
"scope": "UNCHANGED",
"userInteraction": "NONE",
"vectorString": "CVSS:3.1/AV:L/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H",
"version": "3.1"
}
},
{
"other": {
"content": {
"id": "CVE-2026-119434",
"options": [
{
"Exploitation": "none"
},
{
"Automatable": "no"
},
{
"Technical Impact": "partial"
}
],
"role": "CISA Coordinator",
"timestamp": "2026-11-05T20:45:27.204341Z",
"version": "2.0.3"
},
"type": "ssvc"
}
}
],
"providerMetadata": {
"dateUpdated": "2026-11-05T20:45:33.490Z",
"orgId": "134c704f-9b21-4f2e-91b3-4a467353bcc0",
"shortName": "CISA-ADP"
},
"title": "CISA ADP Vulnrichment"
}
],
"cna": {
"affected": [
{
"collectionURL": "https://repo.hex.pm",
"cpes": [
"cpe:2.3:a:silviucpp:ezstd:*:*:*:*:*:*:*:*"
],
"defaultStatus": "unaffected",
"modules": [
"ezstd_nif",
"ezstd"
],
"packageName": "ezstd",
"packageURL": "pkg:hex/ezstd",
"product": "ezstd",
"programFiles": [
"c_src/ezstd_nif.cc",
"src/ezstd_nif.erl",
"src/ezstd.erl"
],
"programRoutines": [
{
"name": "zstd_nif_decompress_using_ddict"
},
{
"name": "ezstd_nif:decompress_using_ddict/2"
},
{
"name": "ezstd:decompress_using_ddict/2"
}
],
"repo": "https://github.com/silviucpp/ezstd",
"vendor": "silviucpp",
"versions": [
{
"lessThan": "1.2.5",
"status": "affected",
"version": "1.0.5",
"versionType": "semver"
}
]
},
{
"collectionURL": "https://github.com",
"cpes": [
"cpe:2.3:a:silviucpp:ezstd:*:*:*:*:*:*:*:*"
],
"defaultStatus": "unaffected",
"modules": [
"ezstd_nif",
"ezstd"
],
"packageName": "silviucpp/ezstd",
"packageURL": "pkg:github/silviucpp/ezstd",
"product": "ezstd",
"programFiles": [
"c_src/ezstd_nif.cc",
"src/ezstd_nif.erl",
"src/ezstd.erl"
],
"programRoutines": [
{
"name": "zstd_nif_decompress_using_ddict"
},
{
"name": "ezstd_nif:decompress_using_ddict/2"
},
{
"name": "ezstd:decompress_using_ddict/2"
}
],
"repo": "https://github.com/silviucpp/ezstd",
"vendor": "silviucpp",
"versions": [
{
"lessThan": "e34f4a4f6dc18d990d1cece2d5fceef8e934a195",
"status": "affected",
"version": "6730128b1d35eca02a2028671421471ff651f423",
"versionType": "git"
}
]
}
],
"cpeApplicability": [
{
"nodes": [
{
"cpeMatch": [
{
"criteria": "cpe:2.3:a:silviucpp:ezstd:*:*:*:*:*:*:*:*",
"versionEndExcluding": "1.2.5",
"versionStartIncluding": "1.0.5",
"vulnerable": true
}
],
"negate": false,
"operator": "OR"
}
],
"operator": "AND"
}
],
"credits": [
{
"lang": "en",
"type": "finder",
"value": "Oz Kaiser"
},
{
"lang": "en",
"type": "reporter",
"value": "Oz Kaiser"
},
{
"lang": "en",
"type": "remediation developer",
"value": "Silviu Caragea"
},
{
"lang": "en",
"type": "coordinator",
"value": "Jonatan M\u00e4nnchen / EEF"
}
],
"dateAssigned": "2026-11-04T17:02:50.000Z",
"descriptions": [
{
"lang": "en",
"supportingMedia": [
{
"base64": false,
"type": "text/html",
"value": "\u003cp\u003eImproper Handling of Highly Compressed Data (Data Amplification) vulnerability in silviucpp ezstd allows an attacker who supplies a zstd frame to \u003ccode\u003eezstd:decompress_using_ddict/2\u003c/code\u003e to exhaust memory and terminate the Erlang VM via a small frame that declares a very large content size.\u003c/p\u003e\n\u003cp\u003e\u003ccode\u003ezstd_nif_decompress_using_ddict\u003c/code\u003e in \u003ccode\u003ec_src/ezstd_nif.cc\u003c/code\u003e reads the frame\u0027s declared content size with \u003ccode\u003eZSTD_getFrameContentSize\u003c/code\u003e and passes it directly to \u003ccode\u003eenif_make_new_binary\u003c/code\u003e, without the 1 GiB \u003ccode\u003eMAX_BUFFER_SIZE\u003c/code\u003e check that \u003ccode\u003eezstd:decompress/1\u003c/code\u003e applies before allocating. A valid 65,550-byte frame of RLE blocks declaring 2 GiB is rejected by \u003ccode\u003eezstd:decompress/1\u003c/code\u003e but is fully allocated and decompressed by the dictionary path. The allocation happens inside a NIF, so when it exceeds the memory available to the VM the operating system kills the whole node rather than the calling process.\u003c/p\u003e\n\u003cp\u003eThis issue affects ezstd: from 1.0.5 before 1.2.5.\u003c/p\u003e"
},
{
"base64": false,
"type": "text/markdown",
"value": "Improper Handling of Highly Compressed Data (Data Amplification) vulnerability in silviucpp ezstd allows an attacker who supplies a zstd frame to `ezstd:decompress_using_ddict/2` to exhaust memory and terminate the Erlang VM via a small frame that declares a very large content size.\n\n`zstd_nif_decompress_using_ddict` in `c_src/ezstd_nif.cc` reads the frame\u0027s declared content size with `ZSTD_getFrameContentSize` and passes it directly to `enif_make_new_binary`, without the 1 GiB `MAX_BUFFER_SIZE` check that `ezstd:decompress/1` applies before allocating. A valid 65,550-byte frame of RLE blocks declaring 2 GiB is rejected by `ezstd:decompress/1` but is fully allocated and decompressed by the dictionary path. The allocation happens inside a NIF, so when it exceeds the memory available to the VM the operating system kills the whole node rather than the calling process.\n\nThis issue affects ezstd: from 1.0.5 before 1.2.5."
}
],
"value": "Improper Handling of Highly Compressed Data (Data Amplification) vulnerability in silviucpp ezstd allows an attacker who supplies a zstd frame to ezstd:decompress_using_ddict/2 to exhaust memory and terminate the Erlang VM via a small frame that declares a very large content size.\n\nzstd_nif_decompress_using_ddict in c_src/ezstd_nif.cc reads the frame\u0027s declared content size with ZSTD_getFrameContentSize and passes it directly to enif_make_new_binary, without the 1 GiB MAX_BUFFER_SIZE check that ezstd:decompress/1 applies before allocating. A valid 65,550-byte frame of RLE blocks declaring 2 GiB is rejected by ezstd:decompress/1 but is fully allocated and decompressed by the dictionary path. The allocation happens inside a NIF, so when it exceeds the memory available to the VM the operating system kills the whole node rather than the calling process.\n\nThis issue affects ezstd: from 1.0.5 before 1.2.5."
}
],
"impacts": [
{
"capecId": "CAPEC-130",
"descriptions": [
{
"lang": "en",
"supportingMedia": [
{
"base64": false,
"type": "text/html",
"value": "\u003cp\u003eAn attacker who can submit a zstd frame to a service that decompresses it with a fixed dictionary can crash the entire Erlang VM with a single request of about 65 KiB. Every application and process hosted in that VM is taken down, not only the one that called the decompressor.\u003c/p\u003e"
},
{
"base64": false,
"type": "text/markdown",
"value": "An attacker who can submit a zstd frame to a service that decompresses it with a fixed dictionary can crash the entire Erlang VM with a single request of about 65 KiB. Every application and process hosted in that VM is taken down, not only the one that called the decompressor."
}
],
"value": "An attacker who can submit a zstd frame to a service that decompresses it with a fixed dictionary can crash the entire Erlang VM with a single request of about 65 KiB. Every application and process hosted in that VM is taken down, not only the one that called the decompressor."
}
]
}
],
"metrics": [
{
"cvssV4_0": {
"Automatable": "NOT_DEFINED",
"Recovery": "NOT_DEFINED",
"Safety": "NOT_DEFINED",
"attackComplexity": "LOW",
"attackRequirements": "NONE",
"attackVector": "LOCAL",
"baseScore": 6.9,
"baseSeverity": "MEDIUM",
"privilegesRequired": "NONE",
"providerUrgency": "NOT_DEFINED",
"subAvailabilityImpact": "NONE",
"subConfidentialityImpact": "NONE",
"subIntegrityImpact": "NONE",
"userInteraction": "NONE",
"valueDensity": "NOT_DEFINED",
"vectorString": "CVSS:4.0/AV:L/AC:L/AT:N/PR:N/UI:N/VC:N/VI:N/VA:H/SC:N/SI:N/SA:N",
"version": "4.0",
"vulnAvailabilityImpact": "HIGH",
"vulnConfidentialityImpact": "NONE",
"vulnIntegrityImpact": "NONE",
"vulnerabilityResponseEffort": "NOT_DEFINED"
},
"format": "CVSS",
"scenarios": [
{
"lang": "en",
"value": "GENERAL"
}
]
}
],
"problemTypes": [
{
"descriptions": [
{
"cweId": "CWE-409",
"description": "CWE-409 Improper Handling of Highly Compressed Data (Data Amplification)",
"lang": "en",
"type": "CWE"
}
]
}
],
"providerMetadata": {
"dateUpdated": "2026-11-05T20:15:56.311Z",
"orgId": "6b3ad84c-e1a6-4bf7-a703-f496b71e49db",
"shortName": "EEF"
},
"references": [
{
"name": "ezstd v1.2.5 release notes",
"tags": [
"release-notes",
"related"
],
"url": "https://github.com/silviucpp/ezstd/releases/tag/v1.2.5"
},
{
"name": "EEF CNA record for CVE-2026-119434",
"tags": [
"related",
"third-party-advisory"
],
"url": "https://cna.erlef.org/cves/CVE-2026-119434.html"
},
{
"name": "OSV record EEF-CVE-2026-119434",
"tags": [
"related"
],
"url": "https://osv.dev/vulnerability/EEF-CVE-2026-119434"
},
{
"name": "Introducing commit 6730128 in silviucpp/ezstd",
"tags": [
"related"
],
"url": "https://github.com/silviucpp/ezstd/commit/6730128b1d35eca02a2028671421471ff651f423"
},
{
"name": "Fix commit e34f4a4 in silviucpp/ezstd",
"tags": [
"patch"
],
"url": "https://github.com/silviucpp/ezstd/commit/e34f4a4f6dc18d990d1cece2d5fceef8e934a195"
}
],
"source": {
"discovery": "EXTERNAL"
},
"title": "Decompression bomb in ezstd:decompress_using_ddict/2 allows BEAM memory exhaustion via unbounded output buffer",
"workarounds": [
{
"lang": "en",
"supportingMedia": [
{
"base64": false,
"type": "text/html",
"value": "\u003cp\u003eReplace calls to \u003ccode\u003eezstd:decompress_using_ddict/2\u003c/code\u003e with the streaming API: create a context with \u003ccode\u003eezstd:create_decompression_context/1\u003c/code\u003e using a small output buffer, attach the dictionary with \u003ccode\u003eezstd:select_ddict/2\u003c/code\u003e, and decompress with \u003ccode\u003eezstd:decompress_streaming/2\u003c/code\u003e. The streaming path decompresses into the caller-sized buffer and gives up with \u003ccode\u003e{error, decompressor_stuck}\u003c/code\u003e after 1000 chunks, so a 64 KiB buffer caps a single call at 64 MiB of output. Treat that error as a rejected input. Legitimate frames whose output exceeds 1000 times the buffer size fail the same way, so size the buffer for the largest expected payload.\u003c/p\u003e"
},
{
"base64": false,
"type": "text/markdown",
"value": "Replace calls to `ezstd:decompress_using_ddict/2` with the streaming API: create a context with `ezstd:create_decompression_context/1` using a small output buffer, attach the dictionary with `ezstd:select_ddict/2`, and decompress with `ezstd:decompress_streaming/2`. The streaming path decompresses into the caller-sized buffer and gives up with `{error, decompressor_stuck}` after 1000 chunks, so a 64 KiB buffer caps a single call at 64 MiB of output. Treat that error as a rejected input. Legitimate frames whose output exceeds 1000 times the buffer size fail the same way, so size the buffer for the largest expected payload."
}
],
"value": "Replace calls to ezstd:decompress_using_ddict/2 with the streaming API: create a context with ezstd:create_decompression_context/1 using a small output buffer, attach the dictionary with ezstd:select_ddict/2, and decompress with ezstd:decompress_streaming/2. The streaming path decompresses into the caller-sized buffer and gives up with {error, decompressor_stuck} after 1000 chunks, so a 64 KiB buffer caps a single call at 64 MiB of output. Treat that error as a rejected input. Legitimate frames whose output exceeds 1000 times the buffer size fail the same way, so size the buffer for the largest expected payload."
}
],
"x_proofOfConcept": [
{
"lang": "en",
"supportingMedia": [
{
"base64": false,
"type": "text/html",
"value": "\u003col\u003e\n\u003cli\u003eBuild ezstd 1.2.4 and start a VM under an external memory limit, for example a container limited to 256 MiB.\u003c/li\u003e\n\u003cli\u003eCreate any decompression dictionary with \u003ccode\u003eezstd:create_ddict/1\u003c/code\u003e; its content does not matter because the frame carries no dictionary ID.\u003c/li\u003e\n\u003cli\u003eConstruct a zstd frame: the magic number, a frame header descriptor selecting an 8-byte content size, a window descriptor, a little-endian content size of 2 GiB, then 16,383 RLE blocks of 128 KiB each (block header \u003ccode\u003e0x02 0x00 0x10\u003c/code\u003e followed by one payload byte) and a final RLE block with the last-block bit set. The frame is 65,550 bytes.\u003c/li\u003e\n\u003cli\u003eCall \u003ccode\u003eezstd:decompress/1\u003c/code\u003e on the frame: it returns \u003ccode\u003e{error, \u0026lt;\u0026lt;\"frame too large or invalid\"\u0026gt;\u0026gt;}\u003c/code\u003e.\u003c/li\u003e\n\u003cli\u003eCall \u003ccode\u003eezstd:decompress_using_ddict/2\u003c/code\u003e on the same frame with the dictionary: the VM is killed by the operating system (exit status 137).\u003c/li\u003e\n\u003cli\u003eRepeat on 1.2.5: the call returns \u003ccode\u003e{error, \u0026lt;\u0026lt;\"frame too large or invalid\"\u0026gt;\u0026gt;}\u003c/code\u003e and the VM continues.\u003c/li\u003e\n\u003c/ol\u003e"
},
{
"base64": false,
"type": "text/markdown",
"value": "1. Build ezstd 1.2.4 and start a VM under an external memory limit, for example a container limited to 256 MiB.\n2. Create any decompression dictionary with `ezstd:create_ddict/1`; its content does not matter because the frame carries no dictionary ID.\n3. Construct a zstd frame: the magic number, a frame header descriptor selecting an 8-byte content size, a window descriptor, a little-endian content size of 2 GiB, then 16,383 RLE blocks of 128 KiB each (block header `0x02 0x00 0x10` followed by one payload byte) and a final RLE block with the last-block bit set. The frame is 65,550 bytes.\n4. Call `ezstd:decompress/1` on the frame: it returns `{error, \u003c\u003c\"frame too large or invalid\"\u003e\u003e}`.\n5. Call `ezstd:decompress_using_ddict/2` on the same frame with the dictionary: the VM is killed by the operating system (exit status 137).\n6. Repeat on 1.2.5: the call returns `{error, \u003c\u003c\"frame too large or invalid\"\u003e\u003e}` and the VM continues."
}
],
"value": "* Build ezstd 1.2.4 and start a VM under an external memory limit, for example a container limited to 256 MiB.\n* Create any decompression dictionary with ezstd:create_ddict/1; its content does not matter because the frame carries no dictionary ID.\n* Construct a zstd frame: the magic number, a frame header descriptor selecting an 8-byte content size, a window descriptor, a little-endian content size of 2 GiB, then 16,383 RLE blocks of 128 KiB each (block header 0x02 0x00 0x10 followed by one payload byte) and a final RLE block with the last-block bit set. The frame is 65,550 bytes.\n* Call ezstd:decompress/1 on the frame: it returns {error, \u003c\u003c\"frame too large or invalid\"\u003e\u003e}.\n* Call ezstd:decompress_using_ddict/2 on the same frame with the dictionary: the VM is killed by the operating system (exit status 137).\n* Repeat on 1.2.5: the call returns {error, \u003c\u003c\"frame too large or invalid\"\u003e\u003e} and the VM continues."
}
],
"x_technicalAnalysis": [
{
"lang": "en",
"supportingMedia": [
{
"base64": false,
"type": "text/html",
"value": "\u003cp\u003e\u003cstrong\u003e1. Declared size is attacker controlled.\u003c/strong\u003e A zstd frame header carries an optional Frame Content Size field. \u003ccode\u003ezstd_nif_decompress_using_ddict\u003c/code\u003e in \u003ccode\u003ec_src/ezstd_nif.cc\u003c/code\u003e reads it with \u003ccode\u003eZSTD_getFrameContentSize\u003c/code\u003e and rejects only the two sentinel values \u003ccode\u003eZSTD_CONTENTSIZE_UNKNOWN\u003c/code\u003e and \u003ccode\u003eZSTD_CONTENTSIZE_ERROR\u003c/code\u003e.\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003e2. Unbounded allocation.\u003c/strong\u003e The size is passed directly to \u003ccode\u003eenif_make_new_binary\u003c/code\u003e, so a frame declaring 2 GiB makes the NIF request a 2 GiB binary. The sibling \u003ccode\u003ezstd_nif_decompress\u003c/code\u003e rejects any declared size above \u003ccode\u003eMAX_BUFFER_SIZE\u003c/code\u003e (1 GiB) before allocating; the dictionary path omitted that check from the commit that introduced it.\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003e3. Amplification.\u003c/strong\u003e A frame built from RLE blocks, each encoding 128 KiB of a repeated byte in four bytes, declares and genuinely decompresses to 2 GiB from 65,550 bytes of input, a ratio of about 32,760:1. \u003ccode\u003eZSTD_decompress_usingDDict\u003c/code\u003e then fills the buffer. Because this happens inside a NIF, exceeding the memory available to the VM results in the operating system killing the whole BEAM process rather than an Erlang-level exception.\u003c/p\u003e"
},
{
"base64": false,
"type": "text/markdown",
"value": "**1. Declared size is attacker controlled.** A zstd frame header carries an optional Frame Content Size field. `zstd_nif_decompress_using_ddict` in `c_src/ezstd_nif.cc` reads it with `ZSTD_getFrameContentSize` and rejects only the two sentinel values `ZSTD_CONTENTSIZE_UNKNOWN` and `ZSTD_CONTENTSIZE_ERROR`.\n\n**2. Unbounded allocation.** The size is passed directly to `enif_make_new_binary`, so a frame declaring 2 GiB makes the NIF request a 2 GiB binary. The sibling `zstd_nif_decompress` rejects any declared size above `MAX_BUFFER_SIZE` (1 GiB) before allocating; the dictionary path omitted that check from the commit that introduced it.\n\n**3. Amplification.** A frame built from RLE blocks, each encoding 128 KiB of a repeated byte in four bytes, declares and genuinely decompresses to 2 GiB from 65,550 bytes of input, a ratio of about 32,760:1. `ZSTD_decompress_usingDDict` then fills the buffer. Because this happens inside a NIF, exceeding the memory available to the VM results in the operating system killing the whole BEAM process rather than an Erlang-level exception."
}
],
"value": "1. Declared size is attacker controlled. A zstd frame header carries an optional Frame Content Size field. zstd_nif_decompress_using_ddict in c_src/ezstd_nif.cc reads it with ZSTD_getFrameContentSize and rejects only the two sentinel values ZSTD_CONTENTSIZE_UNKNOWN and ZSTD_CONTENTSIZE_ERROR.\n\n2. Unbounded allocation. The size is passed directly to enif_make_new_binary, so a frame declaring 2 GiB makes the NIF request a 2 GiB binary. The sibling zstd_nif_decompress rejects any declared size above MAX_BUFFER_SIZE (1 GiB) before allocating; the dictionary path omitted that check from the commit that introduced it.\n\n3. Amplification. A frame built from RLE blocks, each encoding 128 KiB of a repeated byte in four bytes, declares and genuinely decompresses to 2 GiB from 65,550 bytes of input, a ratio of about 32,760:1. ZSTD_decompress_usingDDict then fills the buffer. Because this happens inside a NIF, exceeding the memory available to the VM results in the operating system killing the whole BEAM process rather than an Erlang-level exception."
}
]
}
},
"cveMetadata": {
"assignerOrgId": "6b3ad84c-e1a6-4bf7-a703-f496b71e49db",
"assignerShortName": "EEF",
"cveId": "CVE-2026-119434",
"datePublished": "2026-11-05T20:15:56.311Z",
"dateReserved": "2026-10-30T10:30:01.015Z",
"dateUpdated": "2026-11-05T20:45:33.490Z",
"state": "PUBLISHED"
},
"dataType": "CVE_RECORD",
"dataVersion": "5.2"
}
CVE-2026-118391 (GCVE-0-2026-118391)
Vulnerability from cvelistv5 – Published: 2026-11-04 13:57 – Updated: 2026-11-05 19:07
VLAI
EPSS
VEX
Title
Unbounded query cache growth from interpolated JSON paths and splice lists in Ecto
Summary
Allocation of Resources Without Limits or Throttling vulnerability in elixir-ecto ecto allows an unauthenticated remote attacker to exhaust memory in the application and in the database server by sending requests with distinct JSON keys or list lengths where the application interpolates them into json_extract_path/2, field[^key] or splice/1. Ecto caches prepared queries per repository in an ETS table with no size limit and no eviction. Interpolated JSON path elements become part of the query expression instead of its parameters, and splice/1 expands into one placeholder per element, so every distinct key or list length creates a new cache entry. With ecto_sql each entry is also a new prepared statement on the database connection that runs it. None of this memory is released until the processes restart.
This issue affects ecto: from 3.4.0 before 3.14.3.
Severity
SSVC
Exploitation: none
Automatable: yes
Technical Impact: partial
CISA Coordinator · CISA-ADP (v2.0.3)
Decision recorded 2026-11-04 20:48 UTC
CWE
- CWE-770 - Allocation of Resources Without Limits or Throttling
Assigner
References
11 references
| URL | Tags |
|---|---|
| https://github.com/elixir-ecto/ecto/security/advi… | vendor-advisory |
| https://cna.erlef.org/cves/CVE-2026-118391.html | related |
| https://osv.dev/vulnerability/EEF-CVE-2026-118391 | related |
| https://github.com/elixir-ecto/ecto/commit/d0e97d… | patch |
| https://ecto.hexdocs.pm/Ecto.Query.API.html#json_… | technical-description |
| https://ecto.hexdocs.pm/Ecto.Query.API.html#splice/1 | technical-description |
| https://github.com/elixir-ecto/ecto/commit/eb03f4… | related |
| https://github.com/elixir-ecto/ecto/issues/4470 | related |
| https://github.com/elixir-ecto/ecto/issues/1031 | related |
| https://github.com/elixir-ecto/ecto/commit/cb2f03… | related |
| https://github.com/elixir-ecto/ecto/commit/eef014… | patch |
Impacted products
2 products
| Vendor | Product | Version | |
|---|---|---|---|
| elixir-ecto | ecto |
Affected:
3.4.0 , < 3.14.3
(semver)
cpe:2.3:a:elixir-ecto:ecto:*:*:*:*:*:*:*:* |
|
| elixir-ecto | ecto |
Affected:
cb2f03964dac324a9771f84f5088e395326e6de8 , < eef014ad090d27d1a5b69c0e32a50eabd44ab3bb
(git)
cpe:2.3:a:elixir-ecto:ecto:*:*:*:*:*:*:*:* |
Date Public
2026-11-04 08:46
{
"containers": {
"adp": [
{
"metrics": [
{
"other": {
"content": {
"id": "CVE-2026-118391",
"options": [
{
"Exploitation": "none"
},
{
"Automatable": "yes"
},
{
"Technical Impact": "partial"
}
],
"role": "CISA Coordinator",
"timestamp": "2026-11-04T20:48:32.374051Z",
"version": "2.0.3"
},
"type": "ssvc"
}
}
],
"providerMetadata": {
"dateUpdated": "2026-11-05T19:07:34.294Z",
"orgId": "134c704f-9b21-4f2e-91b3-4a467353bcc0",
"shortName": "CISA-ADP"
},
"title": "CISA ADP Vulnrichment"
}
],
"cna": {
"affected": [
{
"collectionURL": "https://repo.hex.pm",
"cpes": [
"cpe:2.3:a:elixir-ecto:ecto:*:*:*:*:*:*:*:*"
],
"defaultStatus": "unaffected",
"packageName": "ecto",
"packageURL": "pkg:hex/ecto",
"product": "ecto",
"repo": "https://github.com/elixir-ecto/ecto",
"vendor": "elixir-ecto",
"versions": [
{
"lessThan": "3.14.3",
"status": "affected",
"version": "3.4.0",
"versionType": "semver"
}
]
},
{
"collectionURL": "https://github.com",
"cpes": [
"cpe:2.3:a:elixir-ecto:ecto:*:*:*:*:*:*:*:*"
],
"defaultStatus": "unaffected",
"packageName": "elixir-ecto/ecto",
"packageURL": "pkg:github/elixir-ecto/ecto",
"product": "ecto",
"repo": "https://github.com/elixir-ecto/ecto",
"vendor": "elixir-ecto",
"versions": [
{
"lessThan": "eef014ad090d27d1a5b69c0e32a50eabd44ab3bb",
"status": "affected",
"version": "cb2f03964dac324a9771f84f5088e395326e6de8",
"versionType": "git"
}
]
}
],
"configurations": [
{
"lang": "en",
"supportingMedia": [
{
"base64": false,
"type": "text/html",
"value": "\u003cp\u003eThe application must pass request data into an interpolated JSON path (\u003ccode\u003ejson_extract_path(field, ^path)\u003c/code\u003e, \u003ccode\u003ejson_extract_path(field, [^key])\u003c/code\u003e or \u003ccode\u003efield[^key]\u003c/code\u003e), or a request-controlled list into \u003ccode\u003esplice/1\u003c/code\u003e, and run the query through a repository with the default \u003ccode\u003equery_cache: true\u003c/code\u003e.\u003c/p\u003e"
},
{
"base64": false,
"type": "text/markdown",
"value": "The application must pass request data into an interpolated JSON path (`json_extract_path(field, ^path)`, `json_extract_path(field, [^key])` or `field[^key]`), or a request-controlled list into `splice/1`, and run the query through a repository with the default `query_cache: true`."
}
],
"value": "The application must pass request data into an interpolated JSON path (json_extract_path(field, ^path), json_extract_path(field, [^key]) or field[^key]), or a request-controlled list into splice/1, and run the query through a repository with the default query_cache: true."
}
],
"cpeApplicability": [
{
"nodes": [
{
"cpeMatch": [
{
"criteria": "cpe:2.3:a:elixir-ecto:ecto:*:*:*:*:*:*:*:*",
"versionEndExcluding": "3.14.3",
"versionStartIncluding": "3.4.0",
"vulnerable": true
}
],
"negate": false,
"operator": "OR"
}
],
"operator": "AND"
}
],
"credits": [
{
"lang": "en",
"type": "finder",
"value": "Eric Meadows-J\u00f6nsson"
},
{
"lang": "en",
"type": "reporter",
"value": "Eric Meadows-J\u00f6nsson"
},
{
"lang": "en",
"type": "remediation developer",
"value": "Jos\u00e9 Valim"
}
],
"dateAssigned": "2026-10-30T12:36:37.000Z",
"datePublic": "2026-11-04T08:46:53.000Z",
"descriptions": [
{
"lang": "en",
"supportingMedia": [
{
"base64": false,
"type": "text/html",
"value": "\u003cp\u003eAllocation of Resources Without Limits or Throttling vulnerability in elixir-ecto ecto allows an unauthenticated remote attacker to exhaust memory in the application and in the database server by sending requests with distinct JSON keys or list lengths where the application interpolates them into \u003ccode\u003ejson_extract_path/2\u003c/code\u003e, \u003ccode\u003efield[^key]\u003c/code\u003e or \u003ccode\u003esplice/1\u003c/code\u003e. Ecto caches prepared queries per repository in an ETS table with no size limit and no eviction. Interpolated JSON path elements become part of the query expression instead of its parameters, and \u003ccode\u003esplice/1\u003c/code\u003e expands into one placeholder per element, so every distinct key or list length creates a new cache entry. With ecto_sql each entry is also a new prepared statement on the database connection that runs it. None of this memory is released until the processes restart.\u003c/p\u003e\n\u003cp\u003eThis issue affects ecto: from 3.4.0 before 3.14.3.\u003c/p\u003e"
},
{
"base64": false,
"type": "text/markdown",
"value": "Allocation of Resources Without Limits or Throttling vulnerability in elixir-ecto ecto allows an unauthenticated remote attacker to exhaust memory in the application and in the database server by sending requests with distinct JSON keys or list lengths where the application interpolates them into `json_extract_path/2`, `field[^key]` or `splice/1`. Ecto caches prepared queries per repository in an ETS table with no size limit and no eviction. Interpolated JSON path elements become part of the query expression instead of its parameters, and `splice/1` expands into one placeholder per element, so every distinct key or list length creates a new cache entry. With ecto_sql each entry is also a new prepared statement on the database connection that runs it. None of this memory is released until the processes restart.\n\nThis issue affects ecto: from 3.4.0 before 3.14.3."
}
],
"value": "Allocation of Resources Without Limits or Throttling vulnerability in elixir-ecto ecto allows an unauthenticated remote attacker to exhaust memory in the application and in the database server by sending requests with distinct JSON keys or list lengths where the application interpolates them into json_extract_path/2, field[^key] or splice/1. Ecto caches prepared queries per repository in an ETS table with no size limit and no eviction. Interpolated JSON path elements become part of the query expression instead of its parameters, and splice/1 expands into one placeholder per element, so every distinct key or list length creates a new cache entry. With ecto_sql each entry is also a new prepared statement on the database connection that runs it. None of this memory is released until the processes restart.\n\nThis issue affects ecto: from 3.4.0 before 3.14.3."
}
],
"impacts": [
{
"capecId": "CAPEC-131",
"descriptions": [
{
"lang": "en",
"supportingMedia": [
{
"base64": false,
"type": "text/html",
"value": "\u003cp\u003eAn unauthenticated client can make the application and its database keep memory from every request that carries a new JSON key or list length, when the application passes it into an interpolated JSON path or \u003ccode\u003esplice/1\u003c/code\u003e. Nothing frees that memory until the processes restart. In the proof of concept, 200 requests with 100 MB of keys in total left 191 MB in the BEAM and 289 MB in the PostgreSQL backend of one connection. Sustained requests exhaust memory in the application node, the database server, or both.\u003c/p\u003e"
},
{
"base64": false,
"type": "text/markdown",
"value": "An unauthenticated client can make the application and its database keep memory from every request that carries a new JSON key or list length, when the application passes it into an interpolated JSON path or `splice/1`. Nothing frees that memory until the processes restart. In the proof of concept, 200 requests with 100 MB of keys in total left 191 MB in the BEAM and 289 MB in the PostgreSQL backend of one connection. Sustained requests exhaust memory in the application node, the database server, or both."
}
],
"value": "An unauthenticated client can make the application and its database keep memory from every request that carries a new JSON key or list length, when the application passes it into an interpolated JSON path or splice/1. Nothing frees that memory until the processes restart. In the proof of concept, 200 requests with 100 MB of keys in total left 191 MB in the BEAM and 289 MB in the PostgreSQL backend of one connection. Sustained requests exhaust memory in the application node, the database server, or both."
}
]
}
],
"metrics": [
{
"cvssV4_0": {
"Automatable": "NOT_DEFINED",
"Recovery": "NOT_DEFINED",
"Safety": "NOT_DEFINED",
"attackComplexity": "LOW",
"attackRequirements": "PRESENT",
"attackVector": "NETWORK",
"baseScore": 8.9,
"baseSeverity": "HIGH",
"privilegesRequired": "NONE",
"providerUrgency": "NOT_DEFINED",
"subAvailabilityImpact": "HIGH",
"subConfidentialityImpact": "NONE",
"subIntegrityImpact": "NONE",
"userInteraction": "NONE",
"valueDensity": "NOT_DEFINED",
"vectorString": "CVSS:4.0/AV:N/AC:L/AT:P/PR:N/UI:N/VC:N/VI:N/VA:H/SC:N/SI:N/SA:H",
"version": "4.0",
"vulnAvailabilityImpact": "HIGH",
"vulnConfidentialityImpact": "NONE",
"vulnIntegrityImpact": "NONE",
"vulnerabilityResponseEffort": "NOT_DEFINED"
},
"format": "CVSS",
"scenarios": [
{
"lang": "en",
"value": "GENERAL"
}
]
}
],
"problemTypes": [
{
"descriptions": [
{
"cweId": "CWE-770",
"description": "CWE-770 Allocation of Resources Without Limits or Throttling",
"lang": "en",
"type": "CWE"
}
]
}
],
"providerMetadata": {
"dateUpdated": "2026-11-04T13:57:29.762Z",
"orgId": "6b3ad84c-e1a6-4bf7-a703-f496b71e49db",
"shortName": "EEF"
},
"references": [
{
"name": "GHSA-cxjx-vmm2-qr8r",
"tags": [
"vendor-advisory"
],
"url": "https://github.com/elixir-ecto/ecto/security/advisories/GHSA-cxjx-vmm2-qr8r"
},
{
"name": "EEF CNA record for CVE-2026-118391",
"tags": [
"related"
],
"url": "https://cna.erlef.org/cves/CVE-2026-118391.html"
},
{
"name": "OSV record EEF-CVE-2026-118391",
"tags": [
"related"
],
"url": "https://osv.dev/vulnerability/EEF-CVE-2026-118391"
},
{
"name": "Do not cache queries with interpolated JSON paths, splices, and constants (main)",
"tags": [
"patch"
],
"url": "https://github.com/elixir-ecto/ecto/commit/d0e97d8a6e274c81c25fbf08087da8d8b9bae0ea"
},
{
"name": "Ecto.Query.API.json_extract_path/2",
"tags": [
"technical-description"
],
"url": "https://ecto.hexdocs.pm/Ecto.Query.API.html#json_extract_path/2"
},
{
"name": "Ecto.Query.API.splice/1",
"tags": [
"technical-description"
],
"url": "https://ecto.hexdocs.pm/Ecto.Query.API.html#splice/1"
},
{
"name": "Add splice to fragment (#4228), v3.11.0",
"tags": [
"related"
],
"url": "https://github.com/elixir-ecto/ecto/commit/eb03f45b999e2bf67ffae92811233eb6e56dba55"
},
{
"name": "Should we allow disabling of Ecto cache on specific queries? (#4470)",
"tags": [
"related"
],
"url": "https://github.com/elixir-ecto/ecto/issues/4470"
},
{
"name": "String interpolation will grow cached unboundedly (#1031)",
"tags": [
"related"
],
"url": "https://github.com/elixir-ecto/ecto/issues/1031"
},
{
"name": "Introducing commit cb2f039 in elixir-ecto/ecto",
"tags": [
"related"
],
"url": "https://github.com/elixir-ecto/ecto/commit/cb2f03964dac324a9771f84f5088e395326e6de8"
},
{
"name": "Fix commit eef014a in elixir-ecto/ecto",
"tags": [
"patch"
],
"url": "https://github.com/elixir-ecto/ecto/commit/eef014ad090d27d1a5b69c0e32a50eabd44ab3bb"
}
],
"solutions": [
{
"lang": "en",
"supportingMedia": [
{
"base64": false,
"type": "text/html",
"value": "\u003cp\u003eUpgrade Ecto to 3.14.3 or later. Queries with interpolated JSON path elements or an interpolated \u003ccode\u003esplice/1\u003c/code\u003e list are no longer cached, the same as running them with \u003ccode\u003equery_cache: false\u003c/code\u003e, so distinct keys and list lengths don\u0027t add cache entries.\u003c/p\u003e"
},
{
"base64": false,
"type": "text/markdown",
"value": "Upgrade Ecto to 3.14.3 or later. Queries with interpolated JSON path elements or an interpolated `splice/1` list are no longer cached, the same as running them with `query_cache: false`, so distinct keys and list lengths don\u0027t add cache entries."
}
],
"value": "Upgrade Ecto to 3.14.3 or later. Queries with interpolated JSON path elements or an interpolated splice/1 list are no longer cached, the same as running them with query_cache: false, so distinct keys and list lengths don\u0027t add cache entries."
}
],
"source": {
"discovery": "INTERNAL"
},
"title": "Unbounded query cache growth from interpolated JSON paths and splice lists in Ecto",
"workarounds": [
{
"lang": "en",
"supportingMedia": [
{
"base64": false,
"type": "text/html",
"value": "\u003cp\u003ePass the path as a query parameter so it is not part of the cache key. With PostgreSQL: \u003ccode\u003efragment(\"? #\u0026gt; ?\", i.meta, type(^path, {:array, :string}))\u003c/code\u003e. Alternatively, disable the cache for these queries with \u003ccode\u003eRepo.all(query, query_cache: false)\u003c/code\u003e, or restrict keys and list lengths to a known set before building the query.\u003c/p\u003e"
},
{
"base64": false,
"type": "text/markdown",
"value": "Pass the path as a query parameter so it is not part of the cache key. With PostgreSQL: `fragment(\"? #\u003e ?\", i.meta, type(^path, {:array, :string}))`. Alternatively, disable the cache for these queries with `Repo.all(query, query_cache: false)`, or restrict keys and list lengths to a known set before building the query."
}
],
"value": "Pass the path as a query parameter so it is not part of the cache key. With PostgreSQL: fragment(\"? #\u003e ?\", i.meta, type(^path, {:array, :string})). Alternatively, disable the cache for these queries with Repo.all(query, query_cache: false), or restrict keys and list lengths to a known set before building the query."
}
],
"x_proofOfConcept": [
{
"lang": "en",
"supportingMedia": [
{
"base64": false,
"type": "text/html",
"value": "\u003cp\u003eReproduced on Ecto main (\u003ccode\u003ead8fda5\u003c/code\u003e) with ecto_sql 3.14.0, Postgrex 0.22.4 and PostgreSQL 18.4, on Elixir 1.20.4 and Erlang/OTP 29, with \u003ccode\u003epool_size: 1\u003c/code\u003e.\u003c/p\u003e\n\u003col\u003e\n\u003cli\u003eDefine a handler that filters by a JSON key from the request: \u003ccode\u003eRepo.all(from i in Item, where: json_extract_path(i.meta, ^[key]) == ^value, select: i.id)\u003c/code\u003e.\u003c/li\u003e\n\u003cli\u003eSend 2,000 requests with the same key. The query cache has 1 entry and the connection 1 prepared statement; PostgreSQL backend memory goes from 2 MB to 3 MB.\u003c/li\u003e\n\u003cli\u003eSend 2,000 more requests with distinct short keys. The cache has 2,001 entries and the connection 2,001 prepared statements; backend memory is 21 MB.\u003c/li\u003e\n\u003cli\u003eSend 200 requests with a distinct 500 KB key each (100 MB in total). BEAM binary memory grows by 191 MB and PostgreSQL backend memory by 289 MB, measured after garbage collecting every process.\u003c/li\u003e\n\u003cli\u003eRepeating a key that was already seen adds nothing. \u003ccode\u003efragment(\"? IN (?)\", i.id, splice(^ids))\u003c/code\u003e with list lengths 1 to 300 adds 300 cache entries, while \u003ccode\u003ei.id in ^ids\u003c/code\u003e with the same lengths adds 1.\u003c/li\u003e\n\u003c/ol\u003e"
},
{
"base64": false,
"type": "text/markdown",
"value": "Reproduced on Ecto main (`ad8fda5`) with ecto_sql 3.14.0, Postgrex 0.22.4 and PostgreSQL 18.4, on Elixir 1.20.4 and Erlang/OTP 29, with `pool_size: 1`.\n\n1. Define a handler that filters by a JSON key from the request: `Repo.all(from i in Item, where: json_extract_path(i.meta, ^[key]) == ^value, select: i.id)`.\n2. Send 2,000 requests with the same key. The query cache has 1 entry and the connection 1 prepared statement; PostgreSQL backend memory goes from 2 MB to 3 MB.\n3. Send 2,000 more requests with distinct short keys. The cache has 2,001 entries and the connection 2,001 prepared statements; backend memory is 21 MB.\n4. Send 200 requests with a distinct 500 KB key each (100 MB in total). BEAM binary memory grows by 191 MB and PostgreSQL backend memory by 289 MB, measured after garbage collecting every process.\n5. Repeating a key that was already seen adds nothing. `fragment(\"? IN (?)\", i.id, splice(^ids))` with list lengths 1 to 300 adds 300 cache entries, while `i.id in ^ids` with the same lengths adds 1."
}
],
"value": "Reproduced on Ecto main (ad8fda5) with ecto_sql 3.14.0, Postgrex 0.22.4 and PostgreSQL 18.4, on Elixir 1.20.4 and Erlang/OTP 29, with pool_size: 1.\n\n* Define a handler that filters by a JSON key from the request: Repo.all(from i in Item, where: json_extract_path(i.meta, ^[key]) == ^value, select: i.id).\n* Send 2,000 requests with the same key. The query cache has 1 entry and the connection 1 prepared statement; PostgreSQL backend memory goes from 2 MB to 3 MB.\n* Send 2,000 more requests with distinct short keys. The cache has 2,001 entries and the connection 2,001 prepared statements; backend memory is 21 MB.\n* Send 200 requests with a distinct 500 KB key each (100 MB in total). BEAM binary memory grows by 191 MB and PostgreSQL backend memory by 289 MB, measured after garbage collecting every process.\n* Repeating a key that was already seen adds nothing. fragment(\"? IN (?)\", i.id, splice(^ids)) with list lengths 1 to 300 adds 300 cache entries, while i.id in ^ids with the same lengths adds 1."
}
],
"x_technicalAnalysis": [
{
"lang": "en",
"supportingMedia": [
{
"base64": false,
"type": "text/html",
"value": "\u003cp\u003e\u003cstrong\u003e1. Cache keys\u003c/strong\u003e (\u003ccode\u003elib/ecto/query/builder.ex\u003c/code\u003e, \u003ccode\u003elib/ecto/query/planner.ex\u003c/code\u003e)\u003c/p\u003e\n\u003cp\u003e\u003ccode\u003eescape_json_path/2\u003c/code\u003e checks interpolated path elements with \u003ccode\u003ejson_path_element!/1\u003c/code\u003e, and a whole interpolated path with \u003ccode\u003ejson_path!/1\u003c/code\u003e, at runtime and places the resulting strings and integers in the \u003ccode\u003e{:json_extract_path, [], [field, path]}\u003c/code\u003e expression instead of in the query parameters. \u003ccode\u003eEcto.Query.Planner\u003c/code\u003e builds the cache key from the query expressions, so every distinct path is a distinct key. \u003ccode\u003esplice(^list)\u003c/code\u003e keeps the values as parameters but expands into one placeholder per element. \u003ccode\u003ecast_and_merge_params/5\u003c/code\u003e marks \u003ccode\u003e{:in, _}\u003c/code\u003e parameters as not cacheable but keeps \u003ccode\u003e{:splice, _}\u003c/code\u003e parameters cacheable, so every distinct list length is a distinct key, and the entry grows with the length.\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003e2. Cache storage\u003c/strong\u003e (\u003ccode\u003elib/ecto/query/planner.ex\u003c/code\u003e, \u003ccode\u003elib/ecto/repo/supervisor.ex\u003c/code\u003e)\u003c/p\u003e\n\u003cp\u003e\u003ccode\u003eEcto.Query.Planner.query/6\u003c/code\u003e stores each new query with \u003ccode\u003e:ets.insert_new/2\u003c/code\u003e in the table that \u003ccode\u003eEcto.Repo.Supervisor\u003c/code\u003e creates with \u003ccode\u003enew_query_cache/1\u003c/code\u003e, and nothing removes entries. ecto_sql renders the path into the SQL text, for example \u003ccode\u003eWHERE ((i0.\"meta\"#\u0026gt;array[\u0027color\u0027]::text[]) = $1)\u003c/code\u003e, and Postgrex prepares a named statement for each new SQL text on the connection that runs it.\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003e3. Effect\u003c/strong\u003e\u003c/p\u003e\n\u003cp\u003eEach request with a new key or list length adds memory that is never released, in the BEAM, where the cache is shared by the whole repository, and with ecto_sql in the database server, where prepared statements are kept per connection. Large keys amplify this: every 1 MB of request data retained about 2 MB in the application and 3 MB in PostgreSQL. The query cache was disabled for \u003ccode\u003evalues/2\u003c/code\u003e sources after a similar report, but not for these two cases.\u003c/p\u003e"
},
{
"base64": false,
"type": "text/markdown",
"value": "**1. Cache keys** (`lib/ecto/query/builder.ex`, `lib/ecto/query/planner.ex`)\n\n`escape_json_path/2` checks interpolated path elements with `json_path_element!/1`, and a whole interpolated path with `json_path!/1`, at runtime and places the resulting strings and integers in the `{:json_extract_path, [], [field, path]}` expression instead of in the query parameters. `Ecto.Query.Planner` builds the cache key from the query expressions, so every distinct path is a distinct key. `splice(^list)` keeps the values as parameters but expands into one placeholder per element. `cast_and_merge_params/5` marks `{:in, _}` parameters as not cacheable but keeps `{:splice, _}` parameters cacheable, so every distinct list length is a distinct key, and the entry grows with the length.\n\n**2. Cache storage** (`lib/ecto/query/planner.ex`, `lib/ecto/repo/supervisor.ex`)\n\n`Ecto.Query.Planner.query/6` stores each new query with `:ets.insert_new/2` in the table that `Ecto.Repo.Supervisor` creates with `new_query_cache/1`, and nothing removes entries. ecto_sql renders the path into the SQL text, for example `WHERE ((i0.\"meta\"#\u003earray[\u0027color\u0027]::text[]) = $1)`, and Postgrex prepares a named statement for each new SQL text on the connection that runs it.\n\n**3. Effect**\n\nEach request with a new key or list length adds memory that is never released, in the BEAM, where the cache is shared by the whole repository, and with ecto_sql in the database server, where prepared statements are kept per connection. Large keys amplify this: every 1 MB of request data retained about 2 MB in the application and 3 MB in PostgreSQL. The query cache was disabled for `values/2` sources after a similar report, but not for these two cases."
}
],
"value": "1. Cache keys (lib/ecto/query/builder.ex, lib/ecto/query/planner.ex)\n\nescape_json_path/2 checks interpolated path elements with json_path_element!/1, and a whole interpolated path with json_path!/1, at runtime and places the resulting strings and integers in the {:json_extract_path, [], [field, path]} expression instead of in the query parameters. Ecto.Query.Planner builds the cache key from the query expressions, so every distinct path is a distinct key. splice(^list) keeps the values as parameters but expands into one placeholder per element. cast_and_merge_params/5 marks {:in, _} parameters as not cacheable but keeps {:splice, _} parameters cacheable, so every distinct list length is a distinct key, and the entry grows with the length.\n\n2. Cache storage (lib/ecto/query/planner.ex, lib/ecto/repo/supervisor.ex)\n\nEcto.Query.Planner.query/6 stores each new query with :ets.insert_new/2 in the table that Ecto.Repo.Supervisor creates with new_query_cache/1, and nothing removes entries. ecto_sql renders the path into the SQL text, for example WHERE ((i0.\"meta\"#\u003earray[\u0027color\u0027]::text[]) = $1), and Postgrex prepares a named statement for each new SQL text on the connection that runs it.\n\n3. Effect\n\nEach request with a new key or list length adds memory that is never released, in the BEAM, where the cache is shared by the whole repository, and with ecto_sql in the database server, where prepared statements are kept per connection. Large keys amplify this: every 1 MB of request data retained about 2 MB in the application and 3 MB in PostgreSQL. The query cache was disabled for values/2 sources after a similar report, but not for these two cases."
}
]
}
},
"cveMetadata": {
"assignerOrgId": "6b3ad84c-e1a6-4bf7-a703-f496b71e49db",
"assignerShortName": "EEF",
"cveId": "CVE-2026-118391",
"datePublished": "2026-11-04T13:57:29.762Z",
"dateReserved": "2026-10-30T09:45:01.832Z",
"dateUpdated": "2026-11-05T19:07:34.294Z",
"state": "PUBLISHED"
},
"dataType": "CVE_RECORD",
"dataVersion": "5.2"
}
CVE-2026-110826 (GCVE-0-2026-110826)
Vulnerability from cvelistv5 – Published: 2026-11-03 19:19 – Updated: 2026-11-03 19:40
VLAI
EPSS
VEX
Title
AshAuthentication log_out_everywhere leaves sessions valid after a password reset or non-atomic password change
Summary
Improper Session Invalidation after Authentication Credential Change vulnerability in team-alembic ash_authentication allows an attacker holding a victim's session token to stay signed in after the victim resets or changes their password.
With apply_on_password_change? true, AshAuthentication.AddOn.LogOutEverywhere.Transformer adds OnPasswordChange to update actions as a global change guarded by where: changing(hashed_password). That condition is evaluated before the action's before_action hooks run, but AshAuthentication.Strategy.Password.HashPasswordChange sets the new hash in a before_action hook, so for every non-atomic password change the condition is false and the user's stored tokens are not revoked. The generated password reset action and the installer's change_password action are both non-atomic.
This issue affects ash_authentication: from 4.5.0 before 4.15.1 and from 5.0.0-rc.0 before 5.0.0-rc.16.
Severity
SSVC
Exploitation: none
Automatable: no
Technical Impact: partial
CISA Coordinator · CISA-ADP (v2.0.3)
Decision recorded 2026-11-03 19:39 UTC
CWE
- CWE-1466 - Improper Session Invalidation after Authentication Credential Change
Assigner
References
6 references
Impacted products
2 products
| Vendor | Product | Version | |
|---|---|---|---|
| team-alembic | ash_authentication |
Affected:
4.5.0 , < 4.15.1
(semver)
Affected: 5.0.0-rc.0 , < 5.0.0-rc.16 (semver) cpe:2.3:a:team-alembic:ash_authentication:*:*:*:*:*:*:*:* |
|
| team-alembic | ash_authentication |
Affected:
0993f944fbd8d668f941d2ca818da4a1d5917641 , < *
(git)
cpe:2.3:a:team-alembic:ash_authentication:*:*:*:*:*:*:*:* |
{
"containers": {
"adp": [
{
"metrics": [
{
"other": {
"content": {
"id": "CVE-2026-110826",
"options": [
{
"Exploitation": "none"
},
{
"Automatable": "no"
},
{
"Technical Impact": "partial"
}
],
"role": "CISA Coordinator",
"timestamp": "2026-11-03T19:39:56.983242Z",
"version": "2.0.3"
},
"type": "ssvc"
}
}
],
"providerMetadata": {
"dateUpdated": "2026-11-03T19:40:08.522Z",
"orgId": "134c704f-9b21-4f2e-91b3-4a467353bcc0",
"shortName": "CISA-ADP"
},
"title": "CISA ADP Vulnrichment"
}
],
"cna": {
"affected": [
{
"collectionURL": "https://repo.hex.pm",
"cpes": [
"cpe:2.3:a:team-alembic:ash_authentication:*:*:*:*:*:*:*:*"
],
"defaultStatus": "unaffected",
"modules": [
"\u0027Elixir.AshAuthentication.AddOn.LogOutEverywhere.Transformer\u0027",
"\u0027Elixir.AshAuthentication.AddOn.LogOutEverywhere.OnPasswordChange\u0027"
],
"packageName": "ash_authentication",
"packageURL": "pkg:hex/ash_authentication",
"product": "ash_authentication",
"programFiles": [
"lib/ash_authentication/add_ons/log_out_everywhere/transformer.ex",
"lib/ash_authentication/add_ons/log_out_everywhere/on_password_change.ex"
],
"programRoutines": [
{
"name": "\u0027Elixir.AshAuthentication.AddOn.LogOutEverywhere.Transformer\u0027:transform/2"
},
{
"name": "\u0027Elixir.AshAuthentication.AddOn.LogOutEverywhere.OnPasswordChange\u0027:change/3"
}
],
"repo": "https://github.com/team-alembic/ash_authentication",
"vendor": "team-alembic",
"versions": [
{
"lessThan": "4.15.1",
"status": "affected",
"version": "4.5.0",
"versionType": "semver"
},
{
"lessThan": "5.0.0-rc.16",
"status": "affected",
"version": "5.0.0-rc.0",
"versionType": "semver"
}
]
},
{
"collectionURL": "https://github.com",
"cpes": [
"cpe:2.3:a:team-alembic:ash_authentication:*:*:*:*:*:*:*:*"
],
"defaultStatus": "unaffected",
"modules": [
"\u0027Elixir.AshAuthentication.AddOn.LogOutEverywhere.Transformer\u0027",
"\u0027Elixir.AshAuthentication.AddOn.LogOutEverywhere.OnPasswordChange\u0027"
],
"packageName": "team-alembic/ash_authentication",
"packageURL": "pkg:github/team-alembic/ash_authentication",
"product": "ash_authentication",
"programFiles": [
"lib/ash_authentication/add_ons/log_out_everywhere/transformer.ex",
"lib/ash_authentication/add_ons/log_out_everywhere/on_password_change.ex"
],
"programRoutines": [
{
"name": "\u0027Elixir.AshAuthentication.AddOn.LogOutEverywhere.Transformer\u0027:transform/2"
},
{
"name": "\u0027Elixir.AshAuthentication.AddOn.LogOutEverywhere.OnPasswordChange\u0027:change/3"
}
],
"repo": "https://github.com/team-alembic/ash_authentication",
"vendor": "team-alembic",
"versions": [
{
"changes": [
{
"at": "954697f168d55112b5050acddcd936b8bc1fba2b",
"status": "unaffected"
},
{
"at": "2baef4eddeb604569d3564273d03eeef438e002c",
"status": "unaffected"
}
],
"lessThan": "*",
"status": "affected",
"version": "0993f944fbd8d668f941d2ca818da4a1d5917641",
"versionType": "git"
}
]
}
],
"configurations": [
{
"lang": "en",
"supportingMedia": [
{
"base64": false,
"type": "text/html",
"value": "\u003cp\u003eThe \u003ccode\u003elog_out_everywhere\u003c/code\u003e add-on sets \u003ccode\u003eapply_on_password_change? true\u003c/code\u003e, as \u003ccode\u003emix ash_authentication.install\u003c/code\u003e writes, and the password is changed through a non-atomic update action, which includes the generated password reset action and the installer\u0027s \u003ccode\u003echange_password\u003c/code\u003e action. The attacker must already hold one of the victim\u0027s session tokens.\u003c/p\u003e"
},
{
"base64": false,
"type": "text/markdown",
"value": "The `log_out_everywhere` add-on sets `apply_on_password_change? true`, as `mix ash_authentication.install` writes, and the password is changed through a non-atomic update action, which includes the generated password reset action and the installer\u0027s `change_password` action. The attacker must already hold one of the victim\u0027s session tokens."
}
],
"value": "The log_out_everywhere add-on sets apply_on_password_change? true, as mix ash_authentication.install writes, and the password is changed through a non-atomic update action, which includes the generated password reset action and the installer\u0027s change_password action. The attacker must already hold one of the victim\u0027s session tokens."
}
],
"cpeApplicability": [
{
"nodes": [
{
"cpeMatch": [
{
"criteria": "cpe:2.3:a:team-alembic:ash_authentication:*:*:*:*:*:*:*:*",
"versionEndExcluding": "4.15.1",
"versionStartIncluding": "4.5.0",
"vulnerable": true
},
{
"criteria": "cpe:2.3:a:team-alembic:ash_authentication:*:*:*:*:*:*:*:*",
"versionEndExcluding": "5.0.0-rc.16",
"versionStartIncluding": "5.0.0-rc.0",
"vulnerable": true
}
],
"negate": false,
"operator": "OR"
}
],
"operator": "AND"
}
],
"credits": [
{
"lang": "en",
"type": "finder",
"value": "azmaveth"
},
{
"lang": "en",
"type": "reporter",
"value": "azmaveth"
},
{
"lang": "en",
"type": "remediation developer",
"value": "azmaveth"
}
],
"dateAssigned": "2026-11-03T03:15:53.000Z",
"descriptions": [
{
"lang": "en",
"supportingMedia": [
{
"base64": false,
"type": "text/html",
"value": "\u003cp\u003eImproper Session Invalidation after Authentication Credential Change vulnerability in team-alembic ash_authentication allows an attacker holding a victim\u0027s session token to stay signed in after the victim resets or changes their password.\u003c/p\u003e\n\u003cp\u003eWith \u003ccode\u003eapply_on_password_change? true\u003c/code\u003e, \u003ccode\u003eAshAuthentication.AddOn.LogOutEverywhere.Transformer\u003c/code\u003e adds \u003ccode\u003eOnPasswordChange\u003c/code\u003e to update actions as a global change guarded by \u003ccode\u003ewhere: changing(hashed_password)\u003c/code\u003e. That condition is evaluated before the action\u0027s \u003ccode\u003ebefore_action\u003c/code\u003e hooks run, but \u003ccode\u003eAshAuthentication.Strategy.Password.HashPasswordChange\u003c/code\u003e sets the new hash in a \u003ccode\u003ebefore_action\u003c/code\u003e hook, so for every non-atomic password change the condition is false and the user\u0027s stored tokens are not revoked. The generated password reset action and the installer\u0027s \u003ccode\u003echange_password\u003c/code\u003e action are both non-atomic.\u003c/p\u003e\n\u003cp\u003eThis issue affects ash_authentication: from 4.5.0 before 4.15.1 and from 5.0.0-rc.0 before 5.0.0-rc.16.\u003c/p\u003e"
},
{
"base64": false,
"type": "text/markdown",
"value": "Improper Session Invalidation after Authentication Credential Change vulnerability in team-alembic ash_authentication allows an attacker holding a victim\u0027s session token to stay signed in after the victim resets or changes their password.\n\nWith `apply_on_password_change? true`, `AshAuthentication.AddOn.LogOutEverywhere.Transformer` adds `OnPasswordChange` to update actions as a global change guarded by `where: changing(hashed_password)`. That condition is evaluated before the action\u0027s `before_action` hooks run, but `AshAuthentication.Strategy.Password.HashPasswordChange` sets the new hash in a `before_action` hook, so for every non-atomic password change the condition is false and the user\u0027s stored tokens are not revoked. The generated password reset action and the installer\u0027s `change_password` action are both non-atomic.\n\nThis issue affects ash_authentication: from 4.5.0 before 4.15.1 and from 5.0.0-rc.0 before 5.0.0-rc.16."
}
],
"value": "Improper Session Invalidation after Authentication Credential Change vulnerability in team-alembic ash_authentication allows an attacker holding a victim\u0027s session token to stay signed in after the victim resets or changes their password.\n\nWith apply_on_password_change? true, AshAuthentication.AddOn.LogOutEverywhere.Transformer adds OnPasswordChange to update actions as a global change guarded by where: changing(hashed_password). That condition is evaluated before the action\u0027s before_action hooks run, but AshAuthentication.Strategy.Password.HashPasswordChange sets the new hash in a before_action hook, so for every non-atomic password change the condition is false and the user\u0027s stored tokens are not revoked. The generated password reset action and the installer\u0027s change_password action are both non-atomic.\n\nThis issue affects ash_authentication: from 4.5.0 before 4.15.1 and from 5.0.0-rc.0 before 5.0.0-rc.16."
}
],
"impacts": [
{
"capecId": "CAPEC-60",
"descriptions": [
{
"lang": "en",
"value": "CAPEC-60 Reusing Session IDs (aka Session Replay)"
}
]
}
],
"metrics": [
{
"cvssV4_0": {
"Automatable": "NOT_DEFINED",
"Recovery": "NOT_DEFINED",
"Safety": "NOT_DEFINED",
"attackComplexity": "LOW",
"attackRequirements": "PRESENT",
"attackVector": "NETWORK",
"baseScore": 6.3,
"baseSeverity": "MEDIUM",
"privilegesRequired": "NONE",
"providerUrgency": "NOT_DEFINED",
"subAvailabilityImpact": "NONE",
"subConfidentialityImpact": "NONE",
"subIntegrityImpact": "NONE",
"userInteraction": "NONE",
"valueDensity": "NOT_DEFINED",
"vectorString": "CVSS:4.0/AV:N/AC:L/AT:P/PR:N/UI:N/VC:L/VI:L/VA:N/SC:N/SI:N/SA:N",
"version": "4.0",
"vulnAvailabilityImpact": "NONE",
"vulnConfidentialityImpact": "LOW",
"vulnIntegrityImpact": "LOW",
"vulnerabilityResponseEffort": "NOT_DEFINED"
},
"format": "CVSS",
"scenarios": [
{
"lang": "en",
"value": "GENERAL"
}
]
}
],
"problemTypes": [
{
"descriptions": [
{
"cweId": "CWE-1466",
"description": "CWE-1466 Improper Session Invalidation after Authentication Credential Change",
"lang": "en",
"type": "CWE"
}
]
}
],
"providerMetadata": {
"dateUpdated": "2026-11-03T19:19:26.188Z",
"orgId": "6b3ad84c-e1a6-4bf7-a703-f496b71e49db",
"shortName": "EEF"
},
"references": [
{
"name": "`log_out_everywhere` in AshAuthentication leaves sessions valid after a password reset or non-atomic password change",
"tags": [
"vendor-advisory",
"related"
],
"url": "https://github.com/team-alembic/ash_authentication/security/advisories/GHSA-j6f8-qcj9-gh4r"
},
{
"name": "EEF CNA record for CVE-2026-110826",
"tags": [
"related"
],
"url": "https://cna.erlef.org/cves/CVE-2026-110826.html"
},
{
"name": "OSV record EEF-CVE-2026-110826",
"tags": [
"related"
],
"url": "https://osv.dev/vulnerability/EEF-CVE-2026-110826"
},
{
"name": "Introducing commit 0993f94 in team-alembic/ash_authentication",
"tags": [
"related"
],
"url": "https://github.com/team-alembic/ash_authentication/commit/0993f944fbd8d668f941d2ca818da4a1d5917641"
},
{
"name": "Fix commit 954697f in team-alembic/ash_authentication",
"tags": [
"patch"
],
"url": "https://github.com/team-alembic/ash_authentication/commit/954697f168d55112b5050acddcd936b8bc1fba2b"
},
{
"name": "Fix commit 2baef4e in team-alembic/ash_authentication",
"tags": [
"patch"
],
"url": "https://github.com/team-alembic/ash_authentication/commit/2baef4eddeb604569d3564273d03eeef438e002c"
}
],
"source": {
"discovery": "EXTERNAL"
},
"title": "AshAuthentication log_out_everywhere leaves sessions valid after a password reset or non-atomic password change"
}
},
"cveMetadata": {
"assignerOrgId": "6b3ad84c-e1a6-4bf7-a703-f496b71e49db",
"assignerShortName": "EEF",
"cveId": "CVE-2026-110826",
"datePublished": "2026-11-03T19:19:26.188Z",
"dateReserved": "2026-11-03T03:00:02.226Z",
"dateUpdated": "2026-11-03T19:40:08.522Z",
"state": "PUBLISHED"
},
"dataType": "CVE_RECORD",
"dataVersion": "5.2"
}
CVE-2026-118505 (GCVE-0-2026-118505)
Vulnerability from cvelistv5 – Published: 2026-11-03 15:47 – Updated: 2026-11-03 17:48
VLAI
EPSS
VEX
Title
AshAuthentication brute_force_strategy does not limit sign-in and request actions defined on the resource
Summary
Improper Restriction of Excessive Authentication Attempts vulnerability in team-alembic ash_authentication allows an unauthenticated attacker to guess passwords and one-time passwords without limit.
The password, magic link and OTP strategies enforce brute_force_strategy by adding a preparation only to the actions they generate. When the resource defines one of those actions itself, the strategy uses it as written and nothing checked that it carried the preparation, so the setting compiled and applied no limit. The generators produce exactly this: mix ash_authentication.install writes sign_in_with_password, request_password_reset_token and request_magic_link, mix ash_authentication.upgrade then adds brute_force_strategy to those strategies, and mix ash_authentication.add_strategy.otp writes its OTP actions without the preparation. Separately, with registration_enabled? true the OTP sign-in is a create action, which preparations do not run on, so brute_force_strategy {:preparation, module} never applied to it.
This issue affects ash_authentication: from 5.0.0-rc.4 before 5.0.0-rc.16.
Severity
SSVC
Exploitation: none
Automatable: no
Technical Impact: partial
CISA Coordinator · CISA-ADP (v2.0.3)
Decision recorded 2026-11-03 17:47 UTC
CWE
- CWE-307 - Improper Restriction of Excessive Authentication Attempts
Assigner
References
5 references
| URL | Tags |
|---|---|
| https://github.com/team-alembic/ash_authenticatio… | vendor-advisoryrelated |
| https://cna.erlef.org/cves/CVE-2026-118505.html | related |
| https://osv.dev/vulnerability/EEF-CVE-2026-118505 | related |
| https://github.com/team-alembic/ash_authenticatio… | related |
| https://github.com/team-alembic/ash_authenticatio… | patch |
Impacted products
2 products
| Vendor | Product | Version | |
|---|---|---|---|
| team-alembic | ash_authentication |
Affected:
5.0.0-rc.4 , < 5.0.0-rc.16
(semver)
cpe:2.3:a:team-alembic:ash_authentication:*:*:*:*:*:*:*:* |
|
| team-alembic | ash_authentication |
Affected:
800caa0d4a5bee1f28ad427f4c12fab72003a577 , < 7117b5da5b73ee4cdb372e4adfbaff409b527125
(git)
cpe:2.3:a:team-alembic:ash_authentication:*:*:*:*:*:*:*:* |
{
"containers": {
"adp": [
{
"metrics": [
{
"other": {
"content": {
"id": "CVE-2026-118505",
"options": [
{
"Exploitation": "none"
},
{
"Automatable": "no"
},
{
"Technical Impact": "partial"
}
],
"role": "CISA Coordinator",
"timestamp": "2026-11-03T17:47:46.366690Z",
"version": "2.0.3"
},
"type": "ssvc"
}
}
],
"providerMetadata": {
"dateUpdated": "2026-11-03T17:48:03.096Z",
"orgId": "134c704f-9b21-4f2e-91b3-4a467353bcc0",
"shortName": "CISA-ADP"
},
"title": "CISA ADP Vulnrichment"
}
],
"cna": {
"affected": [
{
"collectionURL": "https://repo.hex.pm",
"cpes": [
"cpe:2.3:a:team-alembic:ash_authentication:*:*:*:*:*:*:*:*"
],
"defaultStatus": "unaffected",
"modules": [
"\u0027Elixir.AshAuthentication.Strategy.Password.Transformer\u0027",
"\u0027Elixir.AshAuthentication.Strategy.MagicLink.Verifier\u0027",
"\u0027Elixir.AshAuthentication.Strategy.Otp.Verifier\u0027",
"\u0027Elixir.Mix.Tasks.AshAuthentication.AddStrategy.Otp\u0027"
],
"packageName": "ash_authentication",
"packageURL": "pkg:hex/ash_authentication",
"product": "ash_authentication",
"programFiles": [
"lib/ash_authentication/strategies/password/transformer.ex",
"lib/ash_authentication/strategies/magic_link/verifier.ex",
"lib/ash_authentication/strategies/otp/verifier.ex",
"lib/mix/tasks/ash_authentication.add_strategy.otp.ex"
],
"programRoutines": [
{
"name": "\u0027Elixir.AshAuthentication.Strategy.Password.Transformer\u0027:transform/2"
},
{
"name": "\u0027Elixir.AshAuthentication.Strategy.MagicLink.Verifier\u0027:verify/2"
},
{
"name": "\u0027Elixir.AshAuthentication.Strategy.Otp.Verifier\u0027:verify/2"
},
{
"name": "\u0027Elixir.Mix.Tasks.AshAuthentication.AddStrategy.Otp\u0027:igniter/1"
}
],
"repo": "https://github.com/team-alembic/ash_authentication",
"vendor": "team-alembic",
"versions": [
{
"lessThan": "5.0.0-rc.16",
"status": "affected",
"version": "5.0.0-rc.4",
"versionType": "semver"
}
]
},
{
"collectionURL": "https://github.com",
"cpes": [
"cpe:2.3:a:team-alembic:ash_authentication:*:*:*:*:*:*:*:*"
],
"defaultStatus": "unaffected",
"modules": [
"\u0027Elixir.AshAuthentication.Strategy.Password.Transformer\u0027",
"\u0027Elixir.AshAuthentication.Strategy.MagicLink.Verifier\u0027",
"\u0027Elixir.AshAuthentication.Strategy.Otp.Verifier\u0027",
"\u0027Elixir.Mix.Tasks.AshAuthentication.AddStrategy.Otp\u0027"
],
"packageName": "team-alembic/ash_authentication",
"packageURL": "pkg:github/team-alembic/ash_authentication",
"product": "ash_authentication",
"programFiles": [
"lib/ash_authentication/strategies/password/transformer.ex",
"lib/ash_authentication/strategies/magic_link/verifier.ex",
"lib/ash_authentication/strategies/otp/verifier.ex",
"lib/mix/tasks/ash_authentication.add_strategy.otp.ex"
],
"programRoutines": [
{
"name": "\u0027Elixir.AshAuthentication.Strategy.Password.Transformer\u0027:transform/2"
},
{
"name": "\u0027Elixir.AshAuthentication.Strategy.MagicLink.Verifier\u0027:verify/2"
},
{
"name": "\u0027Elixir.AshAuthentication.Strategy.Otp.Verifier\u0027:verify/2"
},
{
"name": "\u0027Elixir.Mix.Tasks.AshAuthentication.AddStrategy.Otp\u0027:igniter/1"
}
],
"repo": "https://github.com/team-alembic/ash_authentication",
"vendor": "team-alembic",
"versions": [
{
"lessThan": "7117b5da5b73ee4cdb372e4adfbaff409b527125",
"status": "affected",
"version": "800caa0d4a5bee1f28ad427f4c12fab72003a577",
"versionType": "git"
}
]
}
],
"configurations": [
{
"lang": "en",
"supportingMedia": [
{
"base64": false,
"type": "text/html",
"value": "\u003cp\u003eA password, magic link or OTP strategy sets \u003ccode\u003ebrute_force_strategy\u003c/code\u003e and one of its protected actions (password sign-in, password reset request, magic link request, OTP sign-in or OTP request) is defined on the resource without the matching brute-force preparation, as on resources set up by the installer, the upgrade task or the OTP generator. Alternatively, an OTP strategy sets \u003ccode\u003eregistration_enabled? true\u003c/code\u003e with \u003ccode\u003ebrute_force_strategy {:preparation, module}\u003c/code\u003e. Actions the strategies generate themselves, and the OTP strategy\u0027s \u003ccode\u003e:rate_limit\u003c/code\u003e mode, are not affected.\u003c/p\u003e"
},
{
"base64": false,
"type": "text/markdown",
"value": "A password, magic link or OTP strategy sets `brute_force_strategy` and one of its protected actions (password sign-in, password reset request, magic link request, OTP sign-in or OTP request) is defined on the resource without the matching brute-force preparation, as on resources set up by the installer, the upgrade task or the OTP generator. Alternatively, an OTP strategy sets `registration_enabled? true` with `brute_force_strategy {:preparation, module}`. Actions the strategies generate themselves, and the OTP strategy\u0027s `:rate_limit` mode, are not affected."
}
],
"value": "A password, magic link or OTP strategy sets brute_force_strategy and one of its protected actions (password sign-in, password reset request, magic link request, OTP sign-in or OTP request) is defined on the resource without the matching brute-force preparation, as on resources set up by the installer, the upgrade task or the OTP generator. Alternatively, an OTP strategy sets registration_enabled? true with brute_force_strategy {:preparation, module}. Actions the strategies generate themselves, and the OTP strategy\u0027s :rate_limit mode, are not affected."
}
],
"cpeApplicability": [
{
"nodes": [
{
"cpeMatch": [
{
"criteria": "cpe:2.3:a:team-alembic:ash_authentication:*:*:*:*:*:*:*:*",
"versionEndExcluding": "5.0.0-rc.16",
"versionStartIncluding": "5.0.0-rc.4",
"vulnerable": true
}
],
"negate": false,
"operator": "OR"
}
],
"operator": "AND"
}
],
"credits": [
{
"lang": "en",
"type": "finder",
"value": "azmaveth"
},
{
"lang": "en",
"type": "reporter",
"value": "azmaveth"
},
{
"lang": "en",
"type": "remediation developer",
"value": "azmaveth"
},
{
"lang": "en",
"type": "remediation developer",
"value": "Zach Daniel / Ash Project"
}
],
"dateAssigned": "2026-11-03T02:51:21.000Z",
"descriptions": [
{
"lang": "en",
"supportingMedia": [
{
"base64": false,
"type": "text/html",
"value": "\u003cp\u003eImproper Restriction of Excessive Authentication Attempts vulnerability in team-alembic ash_authentication allows an unauthenticated attacker to guess passwords and one-time passwords without limit.\u003c/p\u003e\n\u003cp\u003eThe password, magic link and OTP strategies enforce \u003ccode\u003ebrute_force_strategy\u003c/code\u003e by adding a preparation only to the actions they generate. When the resource defines one of those actions itself, the strategy uses it as written and nothing checked that it carried the preparation, so the setting compiled and applied no limit. The generators produce exactly this: \u003ccode\u003emix ash_authentication.install\u003c/code\u003e writes \u003ccode\u003esign_in_with_password\u003c/code\u003e, \u003ccode\u003erequest_password_reset_token\u003c/code\u003e and \u003ccode\u003erequest_magic_link\u003c/code\u003e, \u003ccode\u003emix ash_authentication.upgrade\u003c/code\u003e then adds \u003ccode\u003ebrute_force_strategy\u003c/code\u003e to those strategies, and \u003ccode\u003emix ash_authentication.add_strategy.otp\u003c/code\u003e writes its OTP actions without the preparation. Separately, with \u003ccode\u003eregistration_enabled? true\u003c/code\u003e the OTP sign-in is a create action, which preparations do not run on, so \u003ccode\u003ebrute_force_strategy {:preparation, module}\u003c/code\u003e never applied to it.\u003c/p\u003e\n\u003cp\u003eThis issue affects ash_authentication: from 5.0.0-rc.4 before 5.0.0-rc.16.\u003c/p\u003e"
},
{
"base64": false,
"type": "text/markdown",
"value": "Improper Restriction of Excessive Authentication Attempts vulnerability in team-alembic ash_authentication allows an unauthenticated attacker to guess passwords and one-time passwords without limit.\n\nThe password, magic link and OTP strategies enforce `brute_force_strategy` by adding a preparation only to the actions they generate. When the resource defines one of those actions itself, the strategy uses it as written and nothing checked that it carried the preparation, so the setting compiled and applied no limit. The generators produce exactly this: `mix ash_authentication.install` writes `sign_in_with_password`, `request_password_reset_token` and `request_magic_link`, `mix ash_authentication.upgrade` then adds `brute_force_strategy` to those strategies, and `mix ash_authentication.add_strategy.otp` writes its OTP actions without the preparation. Separately, with `registration_enabled? true` the OTP sign-in is a create action, which preparations do not run on, so `brute_force_strategy {:preparation, module}` never applied to it.\n\nThis issue affects ash_authentication: from 5.0.0-rc.4 before 5.0.0-rc.16."
}
],
"value": "Improper Restriction of Excessive Authentication Attempts vulnerability in team-alembic ash_authentication allows an unauthenticated attacker to guess passwords and one-time passwords without limit.\n\nThe password, magic link and OTP strategies enforce brute_force_strategy by adding a preparation only to the actions they generate. When the resource defines one of those actions itself, the strategy uses it as written and nothing checked that it carried the preparation, so the setting compiled and applied no limit. The generators produce exactly this: mix ash_authentication.install writes sign_in_with_password, request_password_reset_token and request_magic_link, mix ash_authentication.upgrade then adds brute_force_strategy to those strategies, and mix ash_authentication.add_strategy.otp writes its OTP actions without the preparation. Separately, with registration_enabled? true the OTP sign-in is a create action, which preparations do not run on, so brute_force_strategy {:preparation, module} never applied to it.\n\nThis issue affects ash_authentication: from 5.0.0-rc.4 before 5.0.0-rc.16."
}
],
"impacts": [
{
"capecId": "CAPEC-49",
"descriptions": [
{
"lang": "en",
"value": "CAPEC-49 Password Brute Forcing"
}
]
}
],
"metrics": [
{
"cvssV4_0": {
"Automatable": "NOT_DEFINED",
"Recovery": "NOT_DEFINED",
"Safety": "NOT_DEFINED",
"attackComplexity": "LOW",
"attackRequirements": "PRESENT",
"attackVector": "NETWORK",
"baseScore": 6.3,
"baseSeverity": "MEDIUM",
"privilegesRequired": "NONE",
"providerUrgency": "NOT_DEFINED",
"subAvailabilityImpact": "NONE",
"subConfidentialityImpact": "NONE",
"subIntegrityImpact": "NONE",
"userInteraction": "NONE",
"valueDensity": "NOT_DEFINED",
"vectorString": "CVSS:4.0/AV:N/AC:L/AT:P/PR:N/UI:N/VC:L/VI:L/VA:N/SC:N/SI:N/SA:N",
"version": "4.0",
"vulnAvailabilityImpact": "NONE",
"vulnConfidentialityImpact": "LOW",
"vulnIntegrityImpact": "LOW",
"vulnerabilityResponseEffort": "NOT_DEFINED"
},
"format": "CVSS",
"scenarios": [
{
"lang": "en",
"value": "GENERAL"
}
]
}
],
"problemTypes": [
{
"descriptions": [
{
"cweId": "CWE-307",
"description": "CWE-307 Improper Restriction of Excessive Authentication Attempts",
"lang": "en",
"type": "CWE"
}
]
}
],
"providerMetadata": {
"dateUpdated": "2026-11-03T15:47:36.067Z",
"orgId": "6b3ad84c-e1a6-4bf7-a703-f496b71e49db",
"shortName": "EEF"
},
"references": [
{
"name": "`brute_force_strategy` in AshAuthentication does not protect sign-in and request actions defined on the resource, allowing unlimited password and one-time password guessing",
"tags": [
"vendor-advisory",
"related"
],
"url": "https://github.com/team-alembic/ash_authentication/security/advisories/GHSA-55gf-3263-4r39"
},
{
"name": "EEF CNA record for CVE-2026-118505",
"tags": [
"related"
],
"url": "https://cna.erlef.org/cves/CVE-2026-118505.html"
},
{
"name": "OSV record EEF-CVE-2026-118505",
"tags": [
"related"
],
"url": "https://osv.dev/vulnerability/EEF-CVE-2026-118505"
},
{
"name": "Introducing commit 800caa0 in team-alembic/ash_authentication",
"tags": [
"related"
],
"url": "https://github.com/team-alembic/ash_authentication/commit/800caa0d4a5bee1f28ad427f4c12fab72003a577"
},
{
"name": "Fix commit 7117b5d in team-alembic/ash_authentication",
"tags": [
"patch"
],
"url": "https://github.com/team-alembic/ash_authentication/commit/7117b5da5b73ee4cdb372e4adfbaff409b527125"
}
],
"source": {
"discovery": "EXTERNAL"
},
"title": "AshAuthentication brute_force_strategy does not limit sign-in and request actions defined on the resource",
"workarounds": [
{
"lang": "en",
"supportingMedia": [
{
"base64": false,
"type": "text/html",
"value": "\u003cp\u003eAdd the brute-force preparation to each protected action defined on the resource, or remove the action so the strategy generates it. For \u003ccode\u003ebrute_force_strategy {:audit_log, name}\u003c/code\u003e that is \u003ccode\u003eprepare {AshAuthentication.AddOn.AuditLog.IdentityBruteForcePreparation, action_name: \u0026lt;action\u0026gt;}\u003c/code\u003e on the password sign-in, password reset request, magic link request and OTP request actions, and \u003ccode\u003eprepare {AshAuthentication.AddOn.AuditLog.BruteForcePreparation, action_name: \u0026lt;action\u0026gt;}\u003c/code\u003e on a read OTP sign-in action. For \u003ccode\u003e{:preparation, MyModule}\u003c/code\u003e it is \u003ccode\u003eprepare MyModule\u003c/code\u003e. For OTP with \u003ccode\u003eregistration_enabled? true\u003c/code\u003e, switch to \u003ccode\u003ebrute_force_strategy :rate_limit\u003c/code\u003e.\u003c/p\u003e"
},
{
"base64": false,
"type": "text/markdown",
"value": "Add the brute-force preparation to each protected action defined on the resource, or remove the action so the strategy generates it. For `brute_force_strategy {:audit_log, name}` that is `prepare {AshAuthentication.AddOn.AuditLog.IdentityBruteForcePreparation, action_name: \u003caction\u003e}` on the password sign-in, password reset request, magic link request and OTP request actions, and `prepare {AshAuthentication.AddOn.AuditLog.BruteForcePreparation, action_name: \u003caction\u003e}` on a read OTP sign-in action. For `{:preparation, MyModule}` it is `prepare MyModule`. For OTP with `registration_enabled? true`, switch to `brute_force_strategy :rate_limit`."
}
],
"value": "Add the brute-force preparation to each protected action defined on the resource, or remove the action so the strategy generates it. For brute_force_strategy {:audit_log, name} that is prepare {AshAuthentication.AddOn.AuditLog.IdentityBruteForcePreparation, action_name: \u003caction\u003e} on the password sign-in, password reset request, magic link request and OTP request actions, and prepare {AshAuthentication.AddOn.AuditLog.BruteForcePreparation, action_name: \u003caction\u003e} on a read OTP sign-in action. For {:preparation, MyModule} it is prepare MyModule. For OTP with registration_enabled? true, switch to brute_force_strategy :rate_limit."
}
]
}
},
"cveMetadata": {
"assignerOrgId": "6b3ad84c-e1a6-4bf7-a703-f496b71e49db",
"assignerShortName": "EEF",
"cveId": "CVE-2026-118505",
"datePublished": "2026-11-03T15:47:36.067Z",
"dateReserved": "2026-10-29T16:15:02.036Z",
"dateUpdated": "2026-11-03T17:48:03.096Z",
"state": "PUBLISHED"
},
"dataType": "CVE_RECORD",
"dataVersion": "5.2"
}
CVE-2026-119215 (GCVE-0-2026-119215)
Vulnerability from cvelistv5 – Published: 2026-11-03 15:46 – Updated: 2026-11-03 17:49
VLAI
EPSS
VEX
Title
AshAuthentication TOTP setup lets a user install their own authenticator secret on another user's account
Summary
Authorization Bypass Through User-Controlled Key vulnerability in team-alembic ash_authentication allows a user with an account to install a TOTP secret they control on another user's account, and then sign in as that user or hold their second factor.
AshAuthentication.Strategy.Totp.ConfirmSetupChange verified a setup token but did not check that it was issued to the record being confirmed, so an attacker's own pending secret could be confirmed onto another user's record. The TOTP setup changes, ConfirmSetupChange, GeneratePendingSetupChange and GenerateSecretChange, also marked their changesets as AshAuthentication interactions, which the standard AshAuthenticationInteraction policy bypass lets through. A policy limiting setup and confirm_setup to the actor's own record therefore never ran when an application exposed those actions by record key, and a direct setup call on another user's record returned that user's pending secret and setup token.
This issue affects ash_authentication: from 5.0.0-rc.0 before 5.0.0-rc.16.
Severity
SSVC
Exploitation: none
Automatable: no
Technical Impact: total
CISA Coordinator · CISA-ADP (v2.0.3)
Decision recorded 2026-11-03 17:49 UTC
CWE
- CWE-639 - Authorization Bypass Through User-Controlled Key
Assigner
References
5 references
| URL | Tags |
|---|---|
| https://github.com/team-alembic/ash_authenticatio… | vendor-advisoryrelated |
| https://cna.erlef.org/cves/CVE-2026-119215.html | related |
| https://osv.dev/vulnerability/EEF-CVE-2026-119215 | related |
| https://github.com/team-alembic/ash_authenticatio… | related |
| https://github.com/team-alembic/ash_authenticatio… | patch |
Impacted products
2 products
| Vendor | Product | Version | |
|---|---|---|---|
| team-alembic | ash_authentication |
Affected:
5.0.0-rc.0 , < 5.0.0-rc.16
(semver)
cpe:2.3:a:team-alembic:ash_authentication:*:*:*:*:*:*:*:* |
|
| team-alembic | ash_authentication |
Affected:
6e7a880698614887527a87df206b4eb0c0fbd8c1 , < 4058be0f519c2e9b0d2266e90f9c7d8547d54574
(git)
cpe:2.3:a:team-alembic:ash_authentication:*:*:*:*:*:*:*:* |
{
"containers": {
"adp": [
{
"metrics": [
{
"other": {
"content": {
"id": "CVE-2026-119215",
"options": [
{
"Exploitation": "none"
},
{
"Automatable": "no"
},
{
"Technical Impact": "total"
}
],
"role": "CISA Coordinator",
"timestamp": "2026-11-03T17:49:11.213752Z",
"version": "2.0.3"
},
"type": "ssvc"
}
}
],
"providerMetadata": {
"dateUpdated": "2026-11-03T17:49:28.211Z",
"orgId": "134c704f-9b21-4f2e-91b3-4a467353bcc0",
"shortName": "CISA-ADP"
},
"title": "CISA ADP Vulnrichment"
}
],
"cna": {
"affected": [
{
"collectionURL": "https://repo.hex.pm",
"cpes": [
"cpe:2.3:a:team-alembic:ash_authentication:*:*:*:*:*:*:*:*"
],
"defaultStatus": "unaffected",
"modules": [
"\u0027Elixir.AshAuthentication.Strategy.Totp.ConfirmSetupChange\u0027",
"\u0027Elixir.AshAuthentication.Strategy.Totp.GeneratePendingSetupChange\u0027",
"\u0027Elixir.AshAuthentication.Strategy.Totp.GenerateSecretChange\u0027"
],
"packageName": "ash_authentication",
"packageURL": "pkg:hex/ash_authentication",
"product": "ash_authentication",
"programFiles": [
"lib/ash_authentication/strategies/totp/confirm_setup_change.ex",
"lib/ash_authentication/strategies/totp/generate_pending_setup_change.ex",
"lib/ash_authentication/strategies/totp/generate_secret_change.ex"
],
"programRoutines": [
{
"name": "\u0027Elixir.AshAuthentication.Strategy.Totp.ConfirmSetupChange\u0027:change/3"
},
{
"name": "\u0027Elixir.AshAuthentication.Strategy.Totp.GeneratePendingSetupChange\u0027:change/3"
},
{
"name": "\u0027Elixir.AshAuthentication.Strategy.Totp.GenerateSecretChange\u0027:change/3"
}
],
"repo": "https://github.com/team-alembic/ash_authentication",
"vendor": "team-alembic",
"versions": [
{
"lessThan": "5.0.0-rc.16",
"status": "affected",
"version": "5.0.0-rc.0",
"versionType": "semver"
}
]
},
{
"collectionURL": "https://github.com",
"cpes": [
"cpe:2.3:a:team-alembic:ash_authentication:*:*:*:*:*:*:*:*"
],
"defaultStatus": "unaffected",
"modules": [
"\u0027Elixir.AshAuthentication.Strategy.Totp.ConfirmSetupChange\u0027",
"\u0027Elixir.AshAuthentication.Strategy.Totp.GeneratePendingSetupChange\u0027",
"\u0027Elixir.AshAuthentication.Strategy.Totp.GenerateSecretChange\u0027"
],
"packageName": "team-alembic/ash_authentication",
"packageURL": "pkg:github/team-alembic/ash_authentication",
"product": "ash_authentication",
"programFiles": [
"lib/ash_authentication/strategies/totp/confirm_setup_change.ex",
"lib/ash_authentication/strategies/totp/generate_pending_setup_change.ex",
"lib/ash_authentication/strategies/totp/generate_secret_change.ex"
],
"programRoutines": [
{
"name": "\u0027Elixir.AshAuthentication.Strategy.Totp.ConfirmSetupChange\u0027:change/3"
},
{
"name": "\u0027Elixir.AshAuthentication.Strategy.Totp.GeneratePendingSetupChange\u0027:change/3"
},
{
"name": "\u0027Elixir.AshAuthentication.Strategy.Totp.GenerateSecretChange\u0027:change/3"
}
],
"repo": "https://github.com/team-alembic/ash_authentication",
"vendor": "team-alembic",
"versions": [
{
"lessThan": "4058be0f519c2e9b0d2266e90f9c7d8547d54574",
"status": "affected",
"version": "6e7a880698614887527a87df206b4eb0c0fbd8c1",
"versionType": "git"
}
]
}
],
"configurations": [
{
"lang": "en",
"supportingMedia": [
{
"base64": false,
"type": "text/html",
"value": "\u003cp\u003eAn application exposes the TOTP \u003ccode\u003esetup\u003c/code\u003e or \u003ccode\u003econfirm_setup\u003c/code\u003e action on a record the caller identifies, for example through AshJsonApi, AshGraphql or a controller that passes a looked-up user to \u003ccode\u003eAshAuthentication.Strategy.Totp.Actions.confirm_setup/3\u003c/code\u003e, and the attacker can read the victim\u0027s user record. Installing the attacker\u0027s secret requires \u003ccode\u003econfirm_setup_enabled? true\u003c/code\u003e; signing in with it alone requires \u003ccode\u003esign_in_enabled? true\u003c/code\u003e. The routes AshAuthentication and AshAuthenticationPhoenix provide always use the signed-in user and are not affected.\u003c/p\u003e"
},
{
"base64": false,
"type": "text/markdown",
"value": "An application exposes the TOTP `setup` or `confirm_setup` action on a record the caller identifies, for example through AshJsonApi, AshGraphql or a controller that passes a looked-up user to `AshAuthentication.Strategy.Totp.Actions.confirm_setup/3`, and the attacker can read the victim\u0027s user record. Installing the attacker\u0027s secret requires `confirm_setup_enabled? true`; signing in with it alone requires `sign_in_enabled? true`. The routes AshAuthentication and AshAuthenticationPhoenix provide always use the signed-in user and are not affected."
}
],
"value": "An application exposes the TOTP setup or confirm_setup action on a record the caller identifies, for example through AshJsonApi, AshGraphql or a controller that passes a looked-up user to AshAuthentication.Strategy.Totp.Actions.confirm_setup/3, and the attacker can read the victim\u0027s user record. Installing the attacker\u0027s secret requires confirm_setup_enabled? true; signing in with it alone requires sign_in_enabled? true. The routes AshAuthentication and AshAuthenticationPhoenix provide always use the signed-in user and are not affected."
}
],
"cpeApplicability": [
{
"nodes": [
{
"cpeMatch": [
{
"criteria": "cpe:2.3:a:team-alembic:ash_authentication:*:*:*:*:*:*:*:*",
"versionEndExcluding": "5.0.0-rc.16",
"versionStartIncluding": "5.0.0-rc.0",
"vulnerable": true
}
],
"negate": false,
"operator": "OR"
}
],
"operator": "AND"
}
],
"credits": [
{
"lang": "en",
"type": "finder",
"value": "eliteaja11-dev"
},
{
"lang": "en",
"type": "reporter",
"value": "eliteaja11-dev"
},
{
"lang": "en",
"type": "remediation developer",
"value": "Zach Daniel / Ash Project"
}
],
"dateAssigned": "2026-11-03T03:41:55.000Z",
"descriptions": [
{
"lang": "en",
"supportingMedia": [
{
"base64": false,
"type": "text/html",
"value": "\u003cp\u003eAuthorization Bypass Through User-Controlled Key vulnerability in team-alembic ash_authentication allows a user with an account to install a TOTP secret they control on another user\u0027s account, and then sign in as that user or hold their second factor.\u003c/p\u003e\n\u003cp\u003e\u003ccode\u003eAshAuthentication.Strategy.Totp.ConfirmSetupChange\u003c/code\u003e verified a setup token but did not check that it was issued to the record being confirmed, so an attacker\u0027s own pending secret could be confirmed onto another user\u0027s record. The TOTP setup changes, \u003ccode\u003eConfirmSetupChange\u003c/code\u003e, \u003ccode\u003eGeneratePendingSetupChange\u003c/code\u003e and \u003ccode\u003eGenerateSecretChange\u003c/code\u003e, also marked their changesets as AshAuthentication interactions, which the standard \u003ccode\u003eAshAuthenticationInteraction\u003c/code\u003e policy bypass lets through. A policy limiting \u003ccode\u003esetup\u003c/code\u003e and \u003ccode\u003econfirm_setup\u003c/code\u003e to the actor\u0027s own record therefore never ran when an application exposed those actions by record key, and a direct \u003ccode\u003esetup\u003c/code\u003e call on another user\u0027s record returned that user\u0027s pending secret and setup token.\u003c/p\u003e\n\u003cp\u003eThis issue affects ash_authentication: from 5.0.0-rc.0 before 5.0.0-rc.16.\u003c/p\u003e"
},
{
"base64": false,
"type": "text/markdown",
"value": "Authorization Bypass Through User-Controlled Key vulnerability in team-alembic ash_authentication allows a user with an account to install a TOTP secret they control on another user\u0027s account, and then sign in as that user or hold their second factor.\n\n`AshAuthentication.Strategy.Totp.ConfirmSetupChange` verified a setup token but did not check that it was issued to the record being confirmed, so an attacker\u0027s own pending secret could be confirmed onto another user\u0027s record. The TOTP setup changes, `ConfirmSetupChange`, `GeneratePendingSetupChange` and `GenerateSecretChange`, also marked their changesets as AshAuthentication interactions, which the standard `AshAuthenticationInteraction` policy bypass lets through. A policy limiting `setup` and `confirm_setup` to the actor\u0027s own record therefore never ran when an application exposed those actions by record key, and a direct `setup` call on another user\u0027s record returned that user\u0027s pending secret and setup token.\n\nThis issue affects ash_authentication: from 5.0.0-rc.0 before 5.0.0-rc.16."
}
],
"value": "Authorization Bypass Through User-Controlled Key vulnerability in team-alembic ash_authentication allows a user with an account to install a TOTP secret they control on another user\u0027s account, and then sign in as that user or hold their second factor.\n\nAshAuthentication.Strategy.Totp.ConfirmSetupChange verified a setup token but did not check that it was issued to the record being confirmed, so an attacker\u0027s own pending secret could be confirmed onto another user\u0027s record. The TOTP setup changes, ConfirmSetupChange, GeneratePendingSetupChange and GenerateSecretChange, also marked their changesets as AshAuthentication interactions, which the standard AshAuthenticationInteraction policy bypass lets through. A policy limiting setup and confirm_setup to the actor\u0027s own record therefore never ran when an application exposed those actions by record key, and a direct setup call on another user\u0027s record returned that user\u0027s pending secret and setup token.\n\nThis issue affects ash_authentication: from 5.0.0-rc.0 before 5.0.0-rc.16."
}
],
"impacts": [
{
"capecId": "CAPEC-1",
"descriptions": [
{
"lang": "en",
"value": "CAPEC-1 Accessing Functionality Not Properly Constrained by ACLs"
}
]
}
],
"metrics": [
{
"cvssV4_0": {
"Automatable": "NOT_DEFINED",
"Recovery": "NOT_DEFINED",
"Safety": "NOT_DEFINED",
"attackComplexity": "LOW",
"attackRequirements": "PRESENT",
"attackVector": "NETWORK",
"baseScore": 7.6,
"baseSeverity": "HIGH",
"privilegesRequired": "LOW",
"providerUrgency": "NOT_DEFINED",
"subAvailabilityImpact": "NONE",
"subConfidentialityImpact": "NONE",
"subIntegrityImpact": "NONE",
"userInteraction": "NONE",
"valueDensity": "NOT_DEFINED",
"vectorString": "CVSS:4.0/AV:N/AC:L/AT:P/PR:L/UI:N/VC:H/VI:H/VA:N/SC:N/SI:N/SA:N",
"version": "4.0",
"vulnAvailabilityImpact": "NONE",
"vulnConfidentialityImpact": "HIGH",
"vulnIntegrityImpact": "HIGH",
"vulnerabilityResponseEffort": "NOT_DEFINED"
},
"format": "CVSS",
"scenarios": [
{
"lang": "en",
"value": "GENERAL"
}
]
}
],
"problemTypes": [
{
"descriptions": [
{
"cweId": "CWE-639",
"description": "CWE-639 Authorization Bypass Through User-Controlled Key",
"lang": "en",
"type": "CWE"
}
]
}
],
"providerMetadata": {
"dateUpdated": "2026-11-03T15:46:32.432Z",
"orgId": "6b3ad84c-e1a6-4bf7-a703-f496b71e49db",
"shortName": "EEF"
},
"references": [
{
"name": "TOTP setup in AshAuthentication lets a user install their own authenticator secret on another user\u0027s account",
"tags": [
"vendor-advisory",
"related"
],
"url": "https://github.com/team-alembic/ash_authentication/security/advisories/GHSA-gx67-694c-655c"
},
{
"name": "EEF CNA record for CVE-2026-119215",
"tags": [
"related"
],
"url": "https://cna.erlef.org/cves/CVE-2026-119215.html"
},
{
"name": "OSV record EEF-CVE-2026-119215",
"tags": [
"related"
],
"url": "https://osv.dev/vulnerability/EEF-CVE-2026-119215"
},
{
"name": "Introducing commit 6e7a880 in team-alembic/ash_authentication",
"tags": [
"related"
],
"url": "https://github.com/team-alembic/ash_authentication/commit/6e7a880698614887527a87df206b4eb0c0fbd8c1"
},
{
"name": "Fix commit 4058be0 in team-alembic/ash_authentication",
"tags": [
"patch"
],
"url": "https://github.com/team-alembic/ash_authentication/commit/4058be0f519c2e9b0d2266e90f9c7d8547d54574"
}
],
"source": {
"discovery": "EXTERNAL"
},
"title": "AshAuthentication TOTP setup lets a user install their own authenticator secret on another user\u0027s account",
"workarounds": [
{
"lang": "en",
"supportingMedia": [
{
"base64": false,
"type": "text/html",
"value": "\u003cp\u003eDo not expose \u003ccode\u003esetup\u003c/code\u003e or \u003ccode\u003econfirm_setup\u003c/code\u003e on a record the caller chooses. Call them through \u003ccode\u003eAshAuthentication.Strategy.Totp.Actions\u003c/code\u003e with the signed-in user, as the built-in routes do.\u003c/p\u003e"
},
{
"base64": false,
"type": "text/markdown",
"value": "Do not expose `setup` or `confirm_setup` on a record the caller chooses. Call them through `AshAuthentication.Strategy.Totp.Actions` with the signed-in user, as the built-in routes do."
}
],
"value": "Do not expose setup or confirm_setup on a record the caller chooses. Call them through AshAuthentication.Strategy.Totp.Actions with the signed-in user, as the built-in routes do."
}
]
}
},
"cveMetadata": {
"assignerOrgId": "6b3ad84c-e1a6-4bf7-a703-f496b71e49db",
"assignerShortName": "EEF",
"cveId": "CVE-2026-119215",
"datePublished": "2026-11-03T15:46:32.432Z",
"dateReserved": "2026-11-03T03:30:01.648Z",
"dateUpdated": "2026-11-03T17:49:28.211Z",
"state": "PUBLISHED"
},
"dataType": "CVE_RECORD",
"dataVersion": "5.2"
}
CVE-2026-117680 (GCVE-0-2026-117680)
Vulnerability from cvelistv5 – Published: 2026-11-01 13:29 – Updated: 2026-11-02 19:44
VLAI
EPSS
VEX
Title
jsv treats regex match budget exhaustion as a non-match, bypassing patternProperties and not
Summary
Improper Check for Unusual or Exceptional Conditions vulnerability in the pattern and patternProperties keywords in lud jsv allows an unauthenticated remote attacker to bypass schema validation via a string that matches a schema regex only after heavy backtracking. JSV.Helpers.RegexExt.bounded_match?/2 runs schema regexes with a backtracking budget but calls :re.run/3 without :report_errors, so a match that exhausts the budget returns the same :nomatch as a real non-match. A key that matches a patternProperties regex is then checked against additionalProperties or not at all, and a string that matches a not subschema with a pattern is accepted. Before the budget existed, Regex.match?/2 failed the same way at Erlang's default match limit.
Only schemas whose regex explores a catastrophic branch before the branch that matches are affected. The attacker controls the data, not the schema, and linear patterns never exhaust the budget.
This issue affects jsv: from 0.1.0 before 0.26.2.
Severity
SSVC
Exploitation: poc
Automatable: no
Technical Impact: partial
CISA Coordinator · CISA-ADP (v2.0.3)
Decision recorded 2026-11-02 19:43 UTC
CWE
- CWE-754 - Improper Check for Unusual or Exceptional Conditions
Assigner
References
5 references
| URL | Tags |
|---|---|
| https://github.com/lud/jsv/security/advisories/GH… | vendor-advisoryrelated |
| https://cna.erlef.org/cves/CVE-2026-117680.html | related |
| https://osv.dev/vulnerability/EEF-CVE-2026-117680 | related |
| https://github.com/lud/jsv/commit/302a5d0803468f8… | related |
| https://github.com/lud/jsv/commit/defdb8a68ac7f21… | patch |
Impacted products
{
"containers": {
"adp": [
{
"metrics": [
{
"other": {
"content": {
"id": "CVE-2026-117680",
"options": [
{
"Exploitation": "poc"
},
{
"Automatable": "no"
},
{
"Technical Impact": "partial"
}
],
"role": "CISA Coordinator",
"timestamp": "2026-11-02T19:43:40.753996Z",
"version": "2.0.3"
},
"type": "ssvc"
}
}
],
"providerMetadata": {
"dateUpdated": "2026-11-02T19:44:02.251Z",
"orgId": "134c704f-9b21-4f2e-91b3-4a467353bcc0",
"shortName": "CISA-ADP"
},
"references": [
{
"tags": [
"exploit"
],
"url": "https://github.com/lud/jsv/security/advisories/GHSA-cvmg-hgv6-jqcp"
}
],
"title": "CISA ADP Vulnrichment"
}
],
"cna": {
"affected": [
{
"collectionURL": "https://repo.hex.pm",
"cpes": [
"cpe:2.3:a:lud:jsv:*:*:*:*:*:*:*:*"
],
"defaultStatus": "unaffected",
"modules": [
"\u0027Elixir.JSV.Helpers.RegexExt\u0027",
"\u0027Elixir.JSV.Vocabulary.V202012.Validation\u0027",
"\u0027Elixir.JSV.Vocabulary.V202012.Applicator\u0027",
"\u0027Elixir.JSV\u0027"
],
"packageName": "jsv",
"packageURL": "pkg:hex/jsv",
"product": "jsv",
"programFiles": [
"lib/jsv/helpers/regex_ext.ex",
"lib/jsv/vocabulary/v202012/validation.ex",
"lib/jsv/vocabulary/v202012/applicator.ex",
"lib/jsv.ex"
],
"programRoutines": [
{
"name": "\u0027Elixir.JSV.Helpers.RegexExt\u0027:\u0027bounded_match?\u0027/2"
},
{
"name": "\u0027Elixir.JSV.Vocabulary.V202012.Validation\u0027:validate_keyword/3"
},
{
"name": "\u0027Elixir.JSV.Vocabulary.V202012.Applicator\u0027:validate_keyword/3"
},
{
"name": "\u0027Elixir.JSV\u0027:validate/2"
},
{
"name": "\u0027Elixir.JSV\u0027:validate/3"
}
],
"repo": "https://github.com/lud/jsv",
"vendor": "lud",
"versions": [
{
"lessThan": "0.26.2",
"status": "affected",
"version": "0.1.0",
"versionType": "semver"
}
]
},
{
"collectionURL": "https://github.com",
"cpes": [
"cpe:2.3:a:lud:jsv:*:*:*:*:*:*:*:*"
],
"defaultStatus": "unaffected",
"modules": [
"\u0027Elixir.JSV.Helpers.RegexExt\u0027",
"\u0027Elixir.JSV.Vocabulary.V202012.Validation\u0027",
"\u0027Elixir.JSV.Vocabulary.V202012.Applicator\u0027",
"\u0027Elixir.JSV\u0027"
],
"packageName": "lud/jsv",
"packageURL": "pkg:github/lud/jsv",
"product": "jsv",
"programFiles": [
"lib/jsv/helpers/regex_ext.ex",
"lib/jsv/vocabulary/v202012/validation.ex",
"lib/jsv/vocabulary/v202012/applicator.ex",
"lib/jsv.ex"
],
"programRoutines": [
{
"name": "\u0027Elixir.JSV.Helpers.RegexExt\u0027:\u0027bounded_match?\u0027/2"
},
{
"name": "\u0027Elixir.JSV.Vocabulary.V202012.Validation\u0027:validate_keyword/3"
},
{
"name": "\u0027Elixir.JSV.Vocabulary.V202012.Applicator\u0027:validate_keyword/3"
},
{
"name": "\u0027Elixir.JSV\u0027:validate/2"
},
{
"name": "\u0027Elixir.JSV\u0027:validate/3"
}
],
"repo": "https://github.com/lud/jsv",
"vendor": "lud",
"versions": [
{
"lessThan": "defdb8a68ac7f218aa5dfe85190d484017cb8817",
"status": "affected",
"version": "302a5d0803468f81a1cf08e3b67d8b14cee759ca",
"versionType": "git"
}
]
}
],
"configurations": [
{
"lang": "en",
"supportingMedia": [
{
"base64": false,
"type": "text/html",
"value": "\u003cp\u003eThe schema must contain a \u003ccode\u003epattern\u003c/code\u003e or \u003ccode\u003epatternProperties\u003c/code\u003e regex that PCRE can match only after exploring a catastrophic branch first, such as a nested quantifier in an earlier alternative. Linear patterns never exhaust the budget. The attacker controls the validated data but not the schema.\u003c/p\u003e"
},
{
"base64": false,
"type": "text/markdown",
"value": "The schema must contain a `pattern` or `patternProperties` regex that PCRE can match only after exploring a catastrophic branch first, such as a nested quantifier in an earlier alternative. Linear patterns never exhaust the budget. The attacker controls the validated data but not the schema."
}
],
"value": "The schema must contain a pattern or patternProperties regex that PCRE can match only after exploring a catastrophic branch first, such as a nested quantifier in an earlier alternative. Linear patterns never exhaust the budget. The attacker controls the validated data but not the schema."
}
],
"cpeApplicability": [
{
"nodes": [
{
"cpeMatch": [
{
"criteria": "cpe:2.3:a:lud:jsv:*:*:*:*:*:*:*:*",
"versionEndExcluding": "0.26.2",
"versionStartIncluding": "0.1.0",
"vulnerable": true
}
],
"negate": false,
"operator": "OR"
}
],
"operator": "AND"
}
],
"credits": [
{
"lang": "en",
"type": "finder",
"value": "Lars Hesel Christensen"
},
{
"lang": "en",
"type": "reporter",
"value": "Lars Hesel Christensen"
},
{
"lang": "en",
"type": "remediation developer",
"value": "Ludovic Dem"
},
{
"lang": "en",
"type": "coordinator",
"value": "Jonatan M\u00e4nnchen / EEF"
}
],
"dateAssigned": "2026-10-30T23:20:38.000Z",
"descriptions": [
{
"lang": "en",
"supportingMedia": [
{
"base64": false,
"type": "text/html",
"value": "\u003cp\u003eImproper Check for Unusual or Exceptional Conditions vulnerability in the \u003ccode\u003epattern\u003c/code\u003e and \u003ccode\u003epatternProperties\u003c/code\u003e keywords in lud jsv allows an unauthenticated remote attacker to bypass schema validation via a string that matches a schema regex only after heavy backtracking. \u003ccode\u003eJSV.Helpers.RegexExt.bounded_match?/2\u003c/code\u003e runs schema regexes with a backtracking budget but calls \u003ccode\u003e:re.run/3\u003c/code\u003e without \u003ccode\u003e:report_errors\u003c/code\u003e, so a match that exhausts the budget returns the same \u003ccode\u003e:nomatch\u003c/code\u003e as a real non-match. A key that matches a \u003ccode\u003epatternProperties\u003c/code\u003e regex is then checked against \u003ccode\u003eadditionalProperties\u003c/code\u003e or not at all, and a string that matches a \u003ccode\u003enot\u003c/code\u003e subschema with a \u003ccode\u003epattern\u003c/code\u003e is accepted. Before the budget existed, \u003ccode\u003eRegex.match?/2\u003c/code\u003e failed the same way at Erlang\u0027s default match limit.\u003c/p\u003e\n\u003cp\u003eOnly schemas whose regex explores a catastrophic branch before the branch that matches are affected. The attacker controls the data, not the schema, and linear patterns never exhaust the budget.\u003c/p\u003e\n\u003cp\u003eThis issue affects jsv: from 0.1.0 before 0.26.2.\u003c/p\u003e"
},
{
"base64": false,
"type": "text/markdown",
"value": "Improper Check for Unusual or Exceptional Conditions vulnerability in the `pattern` and `patternProperties` keywords in lud jsv allows an unauthenticated remote attacker to bypass schema validation via a string that matches a schema regex only after heavy backtracking. `JSV.Helpers.RegexExt.bounded_match?/2` runs schema regexes with a backtracking budget but calls `:re.run/3` without `:report_errors`, so a match that exhausts the budget returns the same `:nomatch` as a real non-match. A key that matches a `patternProperties` regex is then checked against `additionalProperties` or not at all, and a string that matches a `not` subschema with a `pattern` is accepted. Before the budget existed, `Regex.match?/2` failed the same way at Erlang\u0027s default match limit.\n\nOnly schemas whose regex explores a catastrophic branch before the branch that matches are affected. The attacker controls the data, not the schema, and linear patterns never exhaust the budget.\n\nThis issue affects jsv: from 0.1.0 before 0.26.2."
}
],
"value": "Improper Check for Unusual or Exceptional Conditions vulnerability in the pattern and patternProperties keywords in lud jsv allows an unauthenticated remote attacker to bypass schema validation via a string that matches a schema regex only after heavy backtracking. JSV.Helpers.RegexExt.bounded_match?/2 runs schema regexes with a backtracking budget but calls :re.run/3 without :report_errors, so a match that exhausts the budget returns the same :nomatch as a real non-match. A key that matches a patternProperties regex is then checked against additionalProperties or not at all, and a string that matches a not subschema with a pattern is accepted. Before the budget existed, Regex.match?/2 failed the same way at Erlang\u0027s default match limit.\n\nOnly schemas whose regex explores a catastrophic branch before the branch that matches are affected. The attacker controls the data, not the schema, and linear patterns never exhaust the budget.\n\nThis issue affects jsv: from 0.1.0 before 0.26.2."
}
],
"impacts": [
{
"capecId": "CAPEC-153",
"descriptions": [
{
"lang": "en",
"supportingMedia": [
{
"base64": false,
"type": "text/html",
"value": "\u003cp\u003eAn attacker pads a string until its match exhausts the budget, so a value under a \u003ccode\u003epatternProperties\u003c/code\u003e key skips its subschema or a string passes a \u003ccode\u003enot\u003c/code\u003e deny-list. The application then accepts input its schema should reject.\u003c/p\u003e"
},
{
"base64": false,
"type": "text/markdown",
"value": "An attacker pads a string until its match exhausts the budget, so a value under a `patternProperties` key skips its subschema or a string passes a `not` deny-list. The application then accepts input its schema should reject."
}
],
"value": "An attacker pads a string until its match exhausts the budget, so a value under a patternProperties key skips its subschema or a string passes a not deny-list. The application then accepts input its schema should reject."
}
]
}
],
"metrics": [
{
"cvssV4_0": {
"Automatable": "NOT_DEFINED",
"Recovery": "NOT_DEFINED",
"Safety": "NOT_DEFINED",
"attackComplexity": "LOW",
"attackRequirements": "PRESENT",
"attackVector": "LOCAL",
"baseScore": 2.1,
"baseSeverity": "LOW",
"privilegesRequired": "NONE",
"providerUrgency": "NOT_DEFINED",
"subAvailabilityImpact": "NONE",
"subConfidentialityImpact": "NONE",
"subIntegrityImpact": "NONE",
"userInteraction": "NONE",
"valueDensity": "NOT_DEFINED",
"vectorString": "CVSS:4.0/AV:L/AC:L/AT:P/PR:N/UI:N/VC:N/VI:L/VA:N/SC:N/SI:N/SA:N",
"version": "4.0",
"vulnAvailabilityImpact": "NONE",
"vulnConfidentialityImpact": "NONE",
"vulnIntegrityImpact": "LOW",
"vulnerabilityResponseEffort": "NOT_DEFINED"
},
"format": "CVSS",
"scenarios": [
{
"lang": "en",
"value": "GENERAL"
}
]
}
],
"problemTypes": [
{
"descriptions": [
{
"cweId": "CWE-754",
"description": "CWE-754 Improper Check for Unusual or Exceptional Conditions",
"lang": "en",
"type": "CWE"
}
]
}
],
"providerMetadata": {
"dateUpdated": "2026-11-01T13:29:36.681Z",
"orgId": "6b3ad84c-e1a6-4bf7-a703-f496b71e49db",
"shortName": "EEF"
},
"references": [
{
"name": "Regex match budget exhaustion is treated as a non-match, bypassing `patternProperties` and `not`",
"tags": [
"vendor-advisory",
"related"
],
"url": "https://github.com/lud/jsv/security/advisories/GHSA-cvmg-hgv6-jqcp"
},
{
"name": "EEF CNA record for CVE-2026-117680",
"tags": [
"related"
],
"url": "https://cna.erlef.org/cves/CVE-2026-117680.html"
},
{
"name": "OSV record EEF-CVE-2026-117680",
"tags": [
"related"
],
"url": "https://osv.dev/vulnerability/EEF-CVE-2026-117680"
},
{
"name": "Introducing commit 302a5d0 in lud/jsv",
"tags": [
"related"
],
"url": "https://github.com/lud/jsv/commit/302a5d0803468f81a1cf08e3b67d8b14cee759ca"
},
{
"name": "Fix commit defdb8a in lud/jsv",
"tags": [
"patch"
],
"url": "https://github.com/lud/jsv/commit/defdb8a68ac7f218aa5dfe85190d484017cb8817"
}
],
"source": {
"discovery": "EXTERNAL"
},
"title": "jsv treats regex match budget exhaustion as a non-match, bypassing patternProperties and not",
"workarounds": [
{
"lang": "en",
"supportingMedia": [
{
"base64": false,
"type": "text/html",
"value": "\u003cp\u003ePair \u003ccode\u003epatternProperties\u003c/code\u003e with \u003ccode\u003eadditionalProperties: false\u003c/code\u003e. A key whose match exhausts the budget is then rejected instead of accepted unchecked. Valid keys that need heavy backtracking are rejected as well.\u003c/p\u003e\n\u003cp\u003eWrite schema regexes without nested quantifiers or overlapping alternatives, and do not express deny-lists as a \u003ccode\u003enot\u003c/code\u003e subschema with a \u003ccode\u003epattern\u003c/code\u003e on untrusted input.\u003c/p\u003e"
},
{
"base64": false,
"type": "text/markdown",
"value": "Pair `patternProperties` with `additionalProperties: false`. A key whose match exhausts the budget is then rejected instead of accepted unchecked. Valid keys that need heavy backtracking are rejected as well.\n\nWrite schema regexes without nested quantifiers or overlapping alternatives, and do not express deny-lists as a `not` subschema with a `pattern` on untrusted input."
}
],
"value": "Pair patternProperties with additionalProperties: false. A key whose match exhausts the budget is then rejected instead of accepted unchecked. Valid keys that need heavy backtracking are rejected as well.\n\nWrite schema regexes without nested quantifiers or overlapping alternatives, and do not express deny-lists as a not subschema with a pattern on untrusted input."
}
],
"x_proofOfConcept": [
{
"lang": "en",
"supportingMedia": [
{
"base64": false,
"type": "text/html",
"value": "\u003col\u003e\n\u003cli\u003eBuild a schema with \u003ccode\u003epatternProperties\u003c/code\u003e mapping the regex \u003ccode\u003e^(?:(a+)+b|a+!)$\u003c/code\u003e to \u003ccode\u003e{\"type\": \"integer\"}\u003c/code\u003e.\u003c/li\u003e\n\u003cli\u003eSubmit an object whose only key is twenty \u003ccode\u003ea\u003c/code\u003e characters followed by \u003ccode\u003e!\u003c/code\u003e and whose value is the string \u003ccode\u003enot an int\u003c/code\u003e. The key matches the regex through its second alternative, but PCRE exhausts the 11,050-step budget in the first alternative.\u003c/li\u003e\n\u003cli\u003e\u003ccode\u003eJSV.validate/2\u003c/code\u003e returns \u003ccode\u003e:ok\u003c/code\u003e. The value is never checked against the subschema.\u003c/li\u003e\n\u003cli\u003eBuild a second schema \u003ccode\u003e{\"not\": {\"pattern\": \"^(?:(a+)+b|a+!)$\"}}\u003c/code\u003e and submit the same string. \u003ccode\u003eJSV.validate/2\u003c/code\u003e returns \u003ccode\u003e:ok\u003c/code\u003e although the string matches the denied pattern.\u003c/li\u003e\n\u003cli\u003eRepeat with the short key \u003ccode\u003eaaa!\u003c/code\u003e. Both schemas now reject the data as expected.\u003c/li\u003e\n\u003c/ol\u003e"
},
{
"base64": false,
"type": "text/markdown",
"value": "1. Build a schema with `patternProperties` mapping the regex `^(?:(a+)+b|a+!)$` to `{\"type\": \"integer\"}`.\n2. Submit an object whose only key is twenty `a` characters followed by `!` and whose value is the string `not an int`. The key matches the regex through its second alternative, but PCRE exhausts the 11,050-step budget in the first alternative.\n3. `JSV.validate/2` returns `:ok`. The value is never checked against the subschema.\n4. Build a second schema `{\"not\": {\"pattern\": \"^(?:(a+)+b|a+!)$\"}}` and submit the same string. `JSV.validate/2` returns `:ok` although the string matches the denied pattern.\n5. Repeat with the short key `aaa!`. Both schemas now reject the data as expected."
}
],
"value": "* Build a schema with patternProperties mapping the regex ^(?:(a+)+b|a+!)$ to {\"type\": \"integer\"}.\n* Submit an object whose only key is twenty a characters followed by ! and whose value is the string not an int. The key matches the regex through its second alternative, but PCRE exhausts the 11,050-step budget in the first alternative.\n* JSV.validate/2 returns :ok. The value is never checked against the subschema.\n* Build a second schema {\"not\": {\"pattern\": \"^(?:(a+)+b|a+!)$\"}} and submit the same string. JSV.validate/2 returns :ok although the string matches the denied pattern.\n* Repeat with the short key aaa!. Both schemas now reject the data as expected."
}
],
"x_technicalAnalysis": [
{
"lang": "en",
"supportingMedia": [
{
"base64": false,
"type": "text/html",
"value": "\u003cp\u003e\u003cstrong\u003e1. Bounded matching.\u003c/strong\u003e \u003ccode\u003eJSV.Helpers.RegexExt.bounded_match?/2\u003c/code\u003e in \u003ccode\u003elib/jsv/helpers/regex_ext.ex\u003c/code\u003e runs each schema regex through \u003ccode\u003e:re.run/3\u003c/code\u003e with \u003ccode\u003ematch_limit\u003c/code\u003e and \u003ccode\u003ematch_limit_recursion\u003c/code\u003e set to \u003ccode\u003e10_000 + 50 * byte_size(subject)\u003c/code\u003e, capped at 10,000,000. The call omits \u003ccode\u003e:report_errors\u003c/code\u003e, so PCRE\u0027s \u003ccode\u003ematch_limit\u003c/code\u003e error is returned as \u003ccode\u003e:nomatch\u003c/code\u003e. The function compares the result to \u003ccode\u003e:match\u003c/code\u003e and returns \u003ccode\u003efalse\u003c/code\u003e for a genuine non-match and for an exhausted budget alike.\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003e2. Keyword evaluation.\u003c/strong\u003e \u003ccode\u003eJSV.Vocabulary.V202012.Validation.validate_keyword/3\u003c/code\u003e uses the result for \u003ccode\u003epattern\u003c/code\u003e, and \u003ccode\u003eJSV.Vocabulary.V202012.Applicator\u003c/code\u003e uses it to decide which \u003ccode\u003epatternProperties\u003c/code\u003e subschemas apply to each key. The draft 7 vocabulary delegates to the same modules. A \u003ccode\u003efalse\u003c/code\u003e from an exhausted budget therefore means a \u003ccode\u003epattern\u003c/code\u003e keyword fails and a \u003ccode\u003epatternProperties\u003c/code\u003e subschema is skipped.\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003e3. Inversion by combinators.\u003c/strong\u003e Under \u003ccode\u003enot\u003c/code\u003e, \u003ccode\u003eanyOf\u003c/code\u003e, \u003ccode\u003eoneOf\u003c/code\u003e, \u003ccode\u003eif\u003c/code\u003e and \u003ccode\u003econtains\u003c/code\u003e, a failing subschema can make the parent succeed, so a false non-match under \u003ccode\u003enot\u003c/code\u003e accepts a string the deny-list should reject. Under \u003ccode\u003epatternProperties\u003c/code\u003e the skipped subschema means the value is checked against \u003ccode\u003eadditionalProperties\u003c/code\u003e or nothing.\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003e4. History.\u003c/strong\u003e Before the budget was introduced, the same keywords called \u003ccode\u003eRegex.match?/2\u003c/code\u003e, which also returns \u003ccode\u003efalse\u003c/code\u003e on Erlang\u0027s default limit of 10,000,000 steps. The budget lowered the input size needed from about 25 bytes to about 20 bytes for the advisory\u0027s pattern.\u003c/p\u003e"
},
{
"base64": false,
"type": "text/markdown",
"value": "**1. Bounded matching.** `JSV.Helpers.RegexExt.bounded_match?/2` in `lib/jsv/helpers/regex_ext.ex` runs each schema regex through `:re.run/3` with `match_limit` and `match_limit_recursion` set to `10_000 + 50 * byte_size(subject)`, capped at 10,000,000. The call omits `:report_errors`, so PCRE\u0027s `match_limit` error is returned as `:nomatch`. The function compares the result to `:match` and returns `false` for a genuine non-match and for an exhausted budget alike.\n\n**2. Keyword evaluation.** `JSV.Vocabulary.V202012.Validation.validate_keyword/3` uses the result for `pattern`, and `JSV.Vocabulary.V202012.Applicator` uses it to decide which `patternProperties` subschemas apply to each key. The draft 7 vocabulary delegates to the same modules. A `false` from an exhausted budget therefore means a `pattern` keyword fails and a `patternProperties` subschema is skipped.\n\n**3. Inversion by combinators.** Under `not`, `anyOf`, `oneOf`, `if` and `contains`, a failing subschema can make the parent succeed, so a false non-match under `not` accepts a string the deny-list should reject. Under `patternProperties` the skipped subschema means the value is checked against `additionalProperties` or nothing.\n\n**4. History.** Before the budget was introduced, the same keywords called `Regex.match?/2`, which also returns `false` on Erlang\u0027s default limit of 10,000,000 steps. The budget lowered the input size needed from about 25 bytes to about 20 bytes for the advisory\u0027s pattern."
}
],
"value": "1. Bounded matching. JSV.Helpers.RegexExt.bounded_match?/2 in lib/jsv/helpers/regex_ext.ex runs each schema regex through :re.run/3 with match_limit and match_limit_recursion set to 10_000 + 50 * byte_size(subject), capped at 10,000,000. The call omits :report_errors, so PCRE\u0027s match_limit error is returned as :nomatch. The function compares the result to :match and returns false for a genuine non-match and for an exhausted budget alike.\n\n2. Keyword evaluation. JSV.Vocabulary.V202012.Validation.validate_keyword/3 uses the result for pattern, and JSV.Vocabulary.V202012.Applicator uses it to decide which patternProperties subschemas apply to each key. The draft 7 vocabulary delegates to the same modules. A false from an exhausted budget therefore means a pattern keyword fails and a patternProperties subschema is skipped.\n\n3. Inversion by combinators. Under not, anyOf, oneOf, if and contains, a failing subschema can make the parent succeed, so a false non-match under not accepts a string the deny-list should reject. Under patternProperties the skipped subschema means the value is checked against additionalProperties or nothing.\n\n4. History. Before the budget was introduced, the same keywords called Regex.match?/2, which also returns false on Erlang\u0027s default limit of 10,000,000 steps. The budget lowered the input size needed from about 25 bytes to about 20 bytes for the advisory\u0027s pattern."
}
]
}
},
"cveMetadata": {
"assignerOrgId": "6b3ad84c-e1a6-4bf7-a703-f496b71e49db",
"assignerShortName": "EEF",
"cveId": "CVE-2026-117680",
"datePublished": "2026-11-01T13:29:36.681Z",
"dateReserved": "2026-10-30T12:45:01.930Z",
"dateUpdated": "2026-11-02T19:44:02.251Z",
"state": "PUBLISHED"
},
"dataType": "CVE_RECORD",
"dataVersion": "5.2"
}
CVE-2026-117656 (GCVE-0-2026-117656)
Vulnerability from cvelistv5 – Published: 2026-10-30 23:55 – Updated: 2026-11-06 15:59
VLAI
EPSS
VEX
Title
Unverified Sign in with Apple callback parameter can override verified ID token claims
Summary
Acceptance of Extraneous Untrusted Data With Trusted Data vulnerability in the Sign in with Apple strategy in pow-auth Assent allows an unauthenticated remote attacker to impersonate another user via the user parameter of the authorization callback.
Apple posts the user's name to the callback endpoint in a user form parameter that the browser submits and the client controls. Assent.Strategy.Apple.callback/2 in lib/assent/strategies/apple.ex decodes that parameter, and fetch_user/2 merges it over the claims of the verified ID token, so the unverified map wins. An attacker who completes a Sign in with Apple login with their own Apple ID and edits the user parameter can replace sub, email, and any other claim. Applications that look up, link, or authorize accounts by the returned subject or email then act on the attacker's values.
This issue affects assent: from 0.1.25 before 0.3.2.
Severity
SSVC
Exploitation: none
Automatable: yes
Technical Impact: total
CISA Coordinator · CISA-ADP (v2.0.3)
Decision recorded 2026-11-06 15:58 UTC
CWE
Assigner
References
5 references
| URL | Tags |
|---|---|
| https://github.com/pow-auth/assent/security/advis… | vendor-advisoryrelated |
| https://cna.erlef.org/cves/CVE-2026-117656.html | related |
| https://osv.dev/vulnerability/EEF-CVE-2026-117656 | related |
| https://github.com/pow-auth/assent/commit/a2d331e… | related |
| https://github.com/pow-auth/assent/commit/18898bd… | patch |
Impacted products
{
"containers": {
"adp": [
{
"metrics": [
{
"other": {
"content": {
"id": "CVE-2026-117656",
"options": [
{
"Exploitation": "none"
},
{
"Automatable": "yes"
},
{
"Technical Impact": "total"
}
],
"role": "CISA Coordinator",
"timestamp": "2026-11-06T15:58:50.558643Z",
"version": "2.0.3"
},
"type": "ssvc"
}
}
],
"providerMetadata": {
"dateUpdated": "2026-11-06T15:59:07.242Z",
"orgId": "134c704f-9b21-4f2e-91b3-4a467353bcc0",
"shortName": "CISA-ADP"
},
"title": "CISA ADP Vulnrichment"
}
],
"cna": {
"affected": [
{
"collectionURL": "https://repo.hex.pm",
"cpes": [
"cpe:2.3:a:pow-auth:assent:*:*:*:*:*:*:*:*"
],
"defaultStatus": "unaffected",
"modules": [
"\u0027Elixir.Assent.Strategy.Apple\u0027"
],
"packageName": "assent",
"packageURL": "pkg:hex/assent",
"product": "assent",
"programFiles": [
"lib/assent/strategies/apple.ex"
],
"programRoutines": [
{
"name": "\u0027Elixir.Assent.Strategy.Apple\u0027:callback/2"
},
{
"name": "\u0027Elixir.Assent.Strategy.Apple\u0027:fetch_user/2"
}
],
"repo": "https://github.com/pow-auth/assent",
"vendor": "pow-auth",
"versions": [
{
"lessThan": "0.3.2",
"status": "affected",
"version": "0.1.25",
"versionType": "semver"
}
]
},
{
"collectionURL": "https://github.com",
"cpes": [
"cpe:2.3:a:pow-auth:assent:*:*:*:*:*:*:*:*"
],
"defaultStatus": "unaffected",
"modules": [
"\u0027Elixir.Assent.Strategy.Apple\u0027"
],
"packageName": "pow-auth/assent",
"packageURL": "pkg:github/pow-auth/assent",
"product": "assent",
"programFiles": [
"lib/assent/strategies/apple.ex"
],
"programRoutines": [
{
"name": "\u0027Elixir.Assent.Strategy.Apple\u0027:callback/2"
},
{
"name": "\u0027Elixir.Assent.Strategy.Apple\u0027:fetch_user/2"
}
],
"repo": "https://github.com/pow-auth/assent",
"vendor": "pow-auth",
"versions": [
{
"lessThan": "18898bde0296152165ae470078490e5798329b1b",
"status": "affected",
"version": "a2d331e8c51376d6c0b04a81aad31e0b4d4d64a3",
"versionType": "git"
}
]
}
],
"cpeApplicability": [
{
"nodes": [
{
"cpeMatch": [
{
"criteria": "cpe:2.3:a:pow-auth:assent:*:*:*:*:*:*:*:*",
"versionEndExcluding": "0.3.2",
"versionStartIncluding": "0.1.25",
"vulnerable": true
}
],
"negate": false,
"operator": "OR"
}
],
"operator": "AND"
}
],
"credits": [
{
"lang": "en",
"type": "finder",
"value": "Iain"
},
{
"lang": "en",
"type": "reporter",
"value": "Iain"
},
{
"lang": "en",
"type": "remediation developer",
"value": "Dan Schultzer"
},
{
"lang": "en",
"type": "remediation verifier",
"value": "Richard Ash"
},
{
"lang": "en",
"type": "coordinator",
"value": "Jonatan M\u00e4nnchen / EEF"
}
],
"dateAssigned": "2026-10-30T23:33:00.000Z",
"descriptions": [
{
"lang": "en",
"supportingMedia": [
{
"base64": false,
"type": "text/html",
"value": "\u003cp\u003eAcceptance of Extraneous Untrusted Data With Trusted Data vulnerability in the Sign in with Apple strategy in pow-auth Assent allows an unauthenticated remote attacker to impersonate another user via the \u003ccode\u003euser\u003c/code\u003e parameter of the authorization callback.\u003c/p\u003e\n\u003cp\u003eApple posts the user\u0027s name to the callback endpoint in a \u003ccode\u003euser\u003c/code\u003e form parameter that the browser submits and the client controls. \u003ccode\u003eAssent.Strategy.Apple.callback/2\u003c/code\u003e in \u003ccode\u003elib/assent/strategies/apple.ex\u003c/code\u003e decodes that parameter, and \u003ccode\u003efetch_user/2\u003c/code\u003e merges it over the claims of the verified ID token, so the unverified map wins. An attacker who completes a Sign in with Apple login with their own Apple ID and edits the \u003ccode\u003euser\u003c/code\u003e parameter can replace \u003ccode\u003esub\u003c/code\u003e, \u003ccode\u003eemail\u003c/code\u003e, and any other claim. Applications that look up, link, or authorize accounts by the returned subject or email then act on the attacker\u0027s values.\u003c/p\u003e\n\u003cp\u003eThis issue affects assent: from 0.1.25 before 0.3.2.\u003c/p\u003e"
},
{
"base64": false,
"type": "text/markdown",
"value": "Acceptance of Extraneous Untrusted Data With Trusted Data vulnerability in the Sign in with Apple strategy in pow-auth Assent allows an unauthenticated remote attacker to impersonate another user via the `user` parameter of the authorization callback.\n\nApple posts the user\u0027s name to the callback endpoint in a `user` form parameter that the browser submits and the client controls. `Assent.Strategy.Apple.callback/2` in `lib/assent/strategies/apple.ex` decodes that parameter, and `fetch_user/2` merges it over the claims of the verified ID token, so the unverified map wins. An attacker who completes a Sign in with Apple login with their own Apple ID and edits the `user` parameter can replace `sub`, `email`, and any other claim. Applications that look up, link, or authorize accounts by the returned subject or email then act on the attacker\u0027s values.\n\nThis issue affects assent: from 0.1.25 before 0.3.2."
}
],
"value": "Acceptance of Extraneous Untrusted Data With Trusted Data vulnerability in the Sign in with Apple strategy in pow-auth Assent allows an unauthenticated remote attacker to impersonate another user via the user parameter of the authorization callback.\n\nApple posts the user\u0027s name to the callback endpoint in a user form parameter that the browser submits and the client controls. Assent.Strategy.Apple.callback/2 in lib/assent/strategies/apple.ex decodes that parameter, and fetch_user/2 merges it over the claims of the verified ID token, so the unverified map wins. An attacker who completes a Sign in with Apple login with their own Apple ID and edits the user parameter can replace sub, email, and any other claim. Applications that look up, link, or authorize accounts by the returned subject or email then act on the attacker\u0027s values.\n\nThis issue affects assent: from 0.1.25 before 0.3.2."
}
],
"impacts": [
{
"capecId": "CAPEC-22",
"descriptions": [
{
"lang": "en",
"supportingMedia": [
{
"base64": false,
"type": "text/html",
"value": "\u003cp\u003eAn attacker with any Apple ID can sign in to an application that uses the Apple strategy as any other user whose Apple subject or email they know. Account lookup, account linking, and email-based access control then act on the attacker-chosen identity.\u003c/p\u003e"
},
{
"base64": false,
"type": "text/markdown",
"value": "An attacker with any Apple ID can sign in to an application that uses the Apple strategy as any other user whose Apple subject or email they know. Account lookup, account linking, and email-based access control then act on the attacker-chosen identity."
}
],
"value": "An attacker with any Apple ID can sign in to an application that uses the Apple strategy as any other user whose Apple subject or email they know. Account lookup, account linking, and email-based access control then act on the attacker-chosen identity."
}
]
}
],
"metrics": [
{
"cvssV4_0": {
"Automatable": "NOT_DEFINED",
"Recovery": "NOT_DEFINED",
"Safety": "NOT_DEFINED",
"attackComplexity": "LOW",
"attackRequirements": "NONE",
"attackVector": "NETWORK",
"baseScore": 9.3,
"baseSeverity": "CRITICAL",
"privilegesRequired": "NONE",
"providerUrgency": "NOT_DEFINED",
"subAvailabilityImpact": "NONE",
"subConfidentialityImpact": "NONE",
"subIntegrityImpact": "NONE",
"userInteraction": "NONE",
"valueDensity": "NOT_DEFINED",
"vectorString": "CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:H/VI:H/VA:N/SC:N/SI:N/SA:N",
"version": "4.0",
"vulnAvailabilityImpact": "NONE",
"vulnConfidentialityImpact": "HIGH",
"vulnIntegrityImpact": "HIGH",
"vulnerabilityResponseEffort": "NOT_DEFINED"
},
"format": "CVSS",
"scenarios": [
{
"lang": "en",
"value": "GENERAL"
}
]
}
],
"problemTypes": [
{
"descriptions": [
{
"cweId": "CWE-290",
"description": "CWE-290 Authentication Bypass by Spoofing",
"lang": "en",
"type": "CWE"
}
]
},
{
"descriptions": [
{
"cweId": "CWE-349",
"description": "CWE-349 Acceptance of Extraneous Untrusted Data With Trusted Data",
"lang": "en",
"type": "CWE"
}
]
}
],
"providerMetadata": {
"dateUpdated": "2026-10-30T23:55:23.426Z",
"orgId": "6b3ad84c-e1a6-4bf7-a703-f496b71e49db",
"shortName": "EEF"
},
"references": [
{
"name": "Unverified Sign in with Apple callback parameter can override verified ID token claims",
"tags": [
"vendor-advisory",
"related"
],
"url": "https://github.com/pow-auth/assent/security/advisories/GHSA-9fr5-g44g-9526"
},
{
"name": "EEF CNA record for CVE-2026-117656",
"tags": [
"related"
],
"url": "https://cna.erlef.org/cves/CVE-2026-117656.html"
},
{
"name": "OSV record EEF-CVE-2026-117656",
"tags": [
"related"
],
"url": "https://osv.dev/vulnerability/EEF-CVE-2026-117656"
},
{
"name": "Introducing commit a2d331e in pow-auth/assent",
"tags": [
"related"
],
"url": "https://github.com/pow-auth/assent/commit/a2d331e8c51376d6c0b04a81aad31e0b4d4d64a3"
},
{
"name": "Fix commit 18898bd in pow-auth/assent",
"tags": [
"patch"
],
"url": "https://github.com/pow-auth/assent/commit/18898bde0296152165ae470078490e5798329b1b"
}
],
"source": {
"discovery": "EXTERNAL"
},
"title": "Unverified Sign in with Apple callback parameter can override verified ID token claims",
"workarounds": [
{
"lang": "en",
"supportingMedia": [
{
"base64": false,
"type": "text/html",
"value": "\u003cp\u003eRemove the \u003ccode\u003euser\u003c/code\u003e parameter from the callback parameters before you pass them to \u003ccode\u003eAssent.Strategy.Apple.callback/2\u003c/code\u003e, for example with \u003ccode\u003eMap.delete(params, \"user\")\u003c/code\u003e. Without the parameter, \u003ccode\u003edecode_user_params/2\u003c/code\u003e yields an empty map and the verified ID token claims pass through unchanged. The user\u0027s name is then unavailable, because Apple delivers it only through this parameter.\u003c/p\u003e"
},
{
"base64": false,
"type": "text/markdown",
"value": "Remove the `user` parameter from the callback parameters before you pass them to `Assent.Strategy.Apple.callback/2`, for example with `Map.delete(params, \"user\")`. Without the parameter, `decode_user_params/2` yields an empty map and the verified ID token claims pass through unchanged. The user\u0027s name is then unavailable, because Apple delivers it only through this parameter."
}
],
"value": "Remove the user parameter from the callback parameters before you pass them to Assent.Strategy.Apple.callback/2, for example with Map.delete(params, \"user\"). Without the parameter, decode_user_params/2 yields an empty map and the verified ID token claims pass through unchanged. The user\u0027s name is then unavailable, because Apple delivers it only through this parameter."
}
],
"x_proofOfConcept": [
{
"lang": "en",
"supportingMedia": [
{
"base64": false,
"type": "text/html",
"value": "\u003col\u003e\n\u003cli\u003eStart the Sign in with Apple flow of an application that uses \u003ccode\u003eAssent.Strategy.Apple\u003c/code\u003e.\u003c/li\u003e\n\u003cli\u003eAuthenticate at Apple with an Apple ID the attacker controls.\u003c/li\u003e\n\u003cli\u003eIntercept the \u003ccode\u003eform_post\u003c/code\u003e request to the application\u0027s callback URL and set the \u003ccode\u003euser\u003c/code\u003e parameter to a JSON object that carries the victim\u0027s values, such as \u003ccode\u003e{\"sub\": \"\u0026lt;victim subject\u0026gt;\", \"email\": \"victim@example.com\"}\u003c/code\u003e.\u003c/li\u003e\n\u003cli\u003eThe application calls \u003ccode\u003eAssent.Strategy.Apple.callback/2\u003c/code\u003e. It validates the attacker\u0027s ID token and returns a user map whose \u003ccode\u003esub\u003c/code\u003e and \u003ccode\u003eemail\u003c/code\u003e are the victim\u0027s values, with \u003ccode\u003eemail_verified\u003c/code\u003e set to true.\u003c/li\u003e\n\u003c/ol\u003e"
},
{
"base64": false,
"type": "text/markdown",
"value": "1. Start the Sign in with Apple flow of an application that uses `Assent.Strategy.Apple`.\n2. Authenticate at Apple with an Apple ID the attacker controls.\n3. Intercept the `form_post` request to the application\u0027s callback URL and set the `user` parameter to a JSON object that carries the victim\u0027s values, such as `{\"sub\": \"\u003cvictim subject\u003e\", \"email\": \"victim@example.com\"}`.\n4. The application calls `Assent.Strategy.Apple.callback/2`. It validates the attacker\u0027s ID token and returns a user map whose `sub` and `email` are the victim\u0027s values, with `email_verified` set to true."
}
],
"value": "* Start the Sign in with Apple flow of an application that uses Assent.Strategy.Apple.\n* Authenticate at Apple with an Apple ID the attacker controls.\n* Intercept the form_post request to the application\u0027s callback URL and set the user parameter to a JSON object that carries the victim\u0027s values, such as {\"sub\": \"\u003cvictim subject\u003e\", \"email\": \"victim@example.com\"}.\n* The application calls Assent.Strategy.Apple.callback/2. It validates the attacker\u0027s ID token and returns a user map whose sub and email are the victim\u0027s values, with email_verified set to true."
}
],
"x_technicalAnalysis": [
{
"lang": "en",
"supportingMedia": [
{
"base64": false,
"type": "text/html",
"value": "\u003cp\u003e\u003cstrong\u003e1. Unverified input.\u003c/strong\u003e Apple\u0027s \u003ccode\u003eform_post\u003c/code\u003e response mode delivers \u003ccode\u003ecode\u003c/code\u003e, \u003ccode\u003eid_token\u003c/code\u003e, \u003ccode\u003estate\u003c/code\u003e, and on the first authorization a \u003ccode\u003euser\u003c/code\u003e parameter that holds a JSON object with the user\u0027s name and email. The browser submits this parameter and Apple does not sign it, so the client controls its content.\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003e2. Merge over verified claims.\u003c/strong\u003e \u003ccode\u003eAssent.Strategy.Apple.callback/2\u003c/code\u003e decodes the \u003ccode\u003euser\u003c/code\u003e parameter with \u003ccode\u003eHelpers.decode_json/2\u003c/code\u003e and stores the map in the config under \u003ccode\u003e:user\u003c/code\u003e. After \u003ccode\u003eAssent.Strategy.OIDC.fetch_user/2\u003c/code\u003e has validated the ID token, \u003ccode\u003eAssent.Strategy.Apple.fetch_user/2\u003c/code\u003e calls \u003ccode\u003eMap.merge/2\u003c/code\u003e with the verified claims first and the decoded parameter second. Keys from the parameter overwrite keys from the token, including \u003ccode\u003esub\u003c/code\u003e and \u003ccode\u003eemail\u003c/code\u003e.\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003e3. Result.\u003c/strong\u003e \u003ccode\u003enormalize/2\u003c/code\u003e reads \u003ccode\u003esub\u003c/code\u003e and \u003ccode\u003eemail\u003c/code\u003e from the merged map and sets \u003ccode\u003eemail_verified\u003c/code\u003e to true, so the application receives attacker-chosen identity fields marked as verified.\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003e4. Fix.\u003c/strong\u003e Version 0.3.2 ignores the parameter by default. With \u003ccode\u003einclude_unverified_name: true\u003c/code\u003e, only \u003ccode\u003egiven_name\u003c/code\u003e and \u003ccode\u003efamily_name\u003c/code\u003e are taken from it, and the verified claims are merged over them.\u003c/p\u003e"
},
{
"base64": false,
"type": "text/markdown",
"value": "**1. Unverified input.** Apple\u0027s `form_post` response mode delivers `code`, `id_token`, `state`, and on the first authorization a `user` parameter that holds a JSON object with the user\u0027s name and email. The browser submits this parameter and Apple does not sign it, so the client controls its content.\n\n**2. Merge over verified claims.** `Assent.Strategy.Apple.callback/2` decodes the `user` parameter with `Helpers.decode_json/2` and stores the map in the config under `:user`. After `Assent.Strategy.OIDC.fetch_user/2` has validated the ID token, `Assent.Strategy.Apple.fetch_user/2` calls `Map.merge/2` with the verified claims first and the decoded parameter second. Keys from the parameter overwrite keys from the token, including `sub` and `email`.\n\n**3. Result.** `normalize/2` reads `sub` and `email` from the merged map and sets `email_verified` to true, so the application receives attacker-chosen identity fields marked as verified.\n\n**4. Fix.** Version 0.3.2 ignores the parameter by default. With `include_unverified_name: true`, only `given_name` and `family_name` are taken from it, and the verified claims are merged over them."
}
],
"value": "1. Unverified input. Apple\u0027s form_post response mode delivers code, id_token, state, and on the first authorization a user parameter that holds a JSON object with the user\u0027s name and email. The browser submits this parameter and Apple does not sign it, so the client controls its content.\n\n2. Merge over verified claims. Assent.Strategy.Apple.callback/2 decodes the user parameter with Helpers.decode_json/2 and stores the map in the config under :user. After Assent.Strategy.OIDC.fetch_user/2 has validated the ID token, Assent.Strategy.Apple.fetch_user/2 calls Map.merge/2 with the verified claims first and the decoded parameter second. Keys from the parameter overwrite keys from the token, including sub and email.\n\n3. Result. normalize/2 reads sub and email from the merged map and sets email_verified to true, so the application receives attacker-chosen identity fields marked as verified.\n\n4. Fix. Version 0.3.2 ignores the parameter by default. With include_unverified_name: true, only given_name and family_name are taken from it, and the verified claims are merged over them."
}
]
}
},
"cveMetadata": {
"assignerOrgId": "6b3ad84c-e1a6-4bf7-a703-f496b71e49db",
"assignerShortName": "EEF",
"cveId": "CVE-2026-117656",
"datePublished": "2026-10-30T23:55:23.426Z",
"dateReserved": "2026-10-30T23:30:02.017Z",
"dateUpdated": "2026-11-06T15:59:07.242Z",
"state": "PUBLISHED"
},
"dataType": "CVE_RECORD",
"dataVersion": "5.2"
}
CVE-2026-117645 (GCVE-0-2026-117645)
Vulnerability from cvelistv5 – Published: 2026-10-30 20:25 – Updated: 2026-11-06 16:02
VLAI
EPSS
VEX
Title
Phoenix.HTML vulnerable to attribute injection via unvalidated attribute names in attributes_escape/1
Summary
Improper Neutralization of Input During Web Page Generation ('Cross-site Scripting') vulnerability in phoenixframework phoenix_html allows an attacker who controls attribute names to inject attributes and script into rendered HTML via Phoenix.HTML.attributes_escape/1.
attributes_escape/1 in lib/phoenix_html.ex escapes attribute values but not attribute names. key_escape/1 passes a binary key through html_escape, which neutralizes quotes and angle brackets but not whitespace, so a key such as x onmouseover ends the attribute and starts a new one. An atom key is only dasherized, so any character passes, including quotes and >. Nested data, aria and phx keys take the same paths. An application is affected when it builds attribute keys from untrusted input and renders them through HEEx <tag {@attrs}>, content_tag/3, or a direct call. Before 3.0.0 the same code lived in Phoenix.HTML.Tag.
This issue affects phoenix_html: from 1.0.0 before 3.3.5 and from 4.0.0 before 4.4.0.
Severity
SSVC
Exploitation: none
Automatable: no
Technical Impact: partial
CISA Coordinator · CISA-ADP (v2.0.3)
Decision recorded 2026-11-06 16:01 UTC
CWE
- CWE-79 - Improper Neutralization of Input During Web Page Generation ('Cross-site Scripting')
Assigner
References
5 references
| URL | Tags |
|---|---|
| https://cna.erlef.org/cves/CVE-2026-117645.html | relatedthird-party-advisory |
| https://osv.dev/vulnerability/EEF-CVE-2026-117645 | related |
| https://github.com/phoenixframework/phoenix_html/… | related |
| https://github.com/phoenixframework/phoenix_html/… | patch |
| https://github.com/phoenixframework/phoenix_html/… | patch |
Impacted products
2 products
| Vendor | Product | Version | |
|---|---|---|---|
| phoenixframework | phoenix_html |
Affected:
1.0.0 , < 3.3.5
(semver)
Affected: 4.0.0 , < 4.4.0 (semver) cpe:2.3:a:phoenixframework:phoenix_html:*:*:*:*:*:*:*:* |
|
| phoenixframework | phoenix_html |
Affected:
6ad3a1d7960891a468d54636054a98e11dfb5111 , < *
(git)
cpe:2.3:a:phoenixframework:phoenix_html:*:*:*:*:*:*:*:* |
{
"containers": {
"adp": [
{
"metrics": [
{
"other": {
"content": {
"id": "CVE-2026-117645",
"options": [
{
"Exploitation": "none"
},
{
"Automatable": "no"
},
{
"Technical Impact": "partial"
}
],
"role": "CISA Coordinator",
"timestamp": "2026-11-06T16:01:55.707158Z",
"version": "2.0.3"
},
"type": "ssvc"
}
}
],
"providerMetadata": {
"dateUpdated": "2026-11-06T16:02:07.740Z",
"orgId": "134c704f-9b21-4f2e-91b3-4a467353bcc0",
"shortName": "CISA-ADP"
},
"title": "CISA ADP Vulnrichment"
}
],
"cna": {
"affected": [
{
"collectionURL": "https://repo.hex.pm",
"cpes": [
"cpe:2.3:a:phoenixframework:phoenix_html:*:*:*:*:*:*:*:*"
],
"defaultStatus": "unaffected",
"modules": [
"\u0027Elixir.Phoenix.HTML\u0027",
"\u0027Elixir.Phoenix.HTML.Tag\u0027"
],
"packageName": "phoenix_html",
"packageURL": "pkg:hex/phoenix_html",
"product": "phoenix_html",
"programFiles": [
"lib/phoenix_html.ex",
"lib/phoenix_html/tag.ex"
],
"programRoutines": [
{
"name": "\u0027Elixir.Phoenix.HTML\u0027:attributes_escape/1"
},
{
"name": "\u0027Elixir.Phoenix.HTML\u0027:build_attrs/1"
},
{
"name": "\u0027Elixir.Phoenix.HTML\u0027:nested_attrs/3"
},
{
"name": "\u0027Elixir.Phoenix.HTML\u0027:key_escape/1"
},
{
"name": "\u0027Elixir.Phoenix.HTML.Tag\u0027:tag/2"
},
{
"name": "\u0027Elixir.Phoenix.HTML.Tag\u0027:content_tag/3"
},
{
"name": "\u0027Elixir.Phoenix.HTML.Tag\u0027:attributes_escape/1"
},
{
"name": "\u0027Elixir.Phoenix.HTML.Tag\u0027:tag_attrs/1"
}
],
"repo": "https://github.com/phoenixframework/phoenix_html",
"vendor": "phoenixframework",
"versions": [
{
"lessThan": "3.3.5",
"status": "affected",
"version": "1.0.0",
"versionType": "semver"
},
{
"lessThan": "4.4.0",
"status": "affected",
"version": "4.0.0",
"versionType": "semver"
}
]
},
{
"collectionURL": "https://github.com",
"cpes": [
"cpe:2.3:a:phoenixframework:phoenix_html:*:*:*:*:*:*:*:*"
],
"defaultStatus": "unaffected",
"modules": [
"\u0027Elixir.Phoenix.HTML\u0027",
"\u0027Elixir.Phoenix.HTML.Tag\u0027"
],
"packageName": "phoenixframework/phoenix_html",
"packageURL": "pkg:github/phoenixframework/phoenix_html",
"product": "phoenix_html",
"programFiles": [
"lib/phoenix_html.ex",
"lib/phoenix_html/tag.ex"
],
"programRoutines": [
{
"name": "\u0027Elixir.Phoenix.HTML\u0027:attributes_escape/1"
},
{
"name": "\u0027Elixir.Phoenix.HTML\u0027:build_attrs/1"
},
{
"name": "\u0027Elixir.Phoenix.HTML\u0027:nested_attrs/3"
},
{
"name": "\u0027Elixir.Phoenix.HTML\u0027:key_escape/1"
},
{
"name": "\u0027Elixir.Phoenix.HTML.Tag\u0027:tag/2"
},
{
"name": "\u0027Elixir.Phoenix.HTML.Tag\u0027:content_tag/3"
},
{
"name": "\u0027Elixir.Phoenix.HTML.Tag\u0027:attributes_escape/1"
},
{
"name": "\u0027Elixir.Phoenix.HTML.Tag\u0027:tag_attrs/1"
}
],
"repo": "https://github.com/phoenixframework/phoenix_html",
"vendor": "phoenixframework",
"versions": [
{
"changes": [
{
"at": "d9e4e6b2d9be49e25fb6d6b4fdbb2008174f01a6",
"status": "unaffected"
},
{
"at": "a47dc8944a773b7e9273d9691dc6744ec793f1de",
"status": "unaffected"
}
],
"lessThan": "*",
"status": "affected",
"version": "6ad3a1d7960891a468d54636054a98e11dfb5111",
"versionType": "git"
}
]
}
],
"cpeApplicability": [
{
"nodes": [
{
"cpeMatch": [
{
"criteria": "cpe:2.3:a:phoenixframework:phoenix_html:*:*:*:*:*:*:*:*",
"versionEndExcluding": "3.3.5",
"versionStartIncluding": "1.0.0",
"vulnerable": true
},
{
"criteria": "cpe:2.3:a:phoenixframework:phoenix_html:*:*:*:*:*:*:*:*",
"versionEndExcluding": "4.4.0",
"versionStartIncluding": "4.0.0",
"vulnerable": true
}
],
"negate": false,
"operator": "OR"
}
],
"operator": "AND"
}
],
"credits": [
{
"lang": "en",
"type": "finder",
"value": "Andrew Moore"
},
{
"lang": "en",
"type": "reporter",
"value": "Andrew Moore"
},
{
"lang": "en",
"type": "remediation developer",
"value": "Steffen Deusch"
},
{
"lang": "en",
"type": "coordinator",
"value": "Jonatan M\u00e4nnchen / EEF"
}
],
"dateAssigned": "2026-10-30T12:20:04.000Z",
"descriptions": [
{
"lang": "en",
"supportingMedia": [
{
"base64": false,
"type": "text/html",
"value": "\u003cp\u003eImproper Neutralization of Input During Web Page Generation (\u0027Cross-site Scripting\u0027) vulnerability in phoenixframework phoenix_html allows an attacker who controls attribute names to inject attributes and script into rendered HTML via \u003ccode\u003ePhoenix.HTML.attributes_escape/1\u003c/code\u003e.\u003c/p\u003e\n\u003cp\u003e\u003ccode\u003eattributes_escape/1\u003c/code\u003e in \u003ccode\u003elib/phoenix_html.ex\u003c/code\u003e escapes attribute values but not attribute names. \u003ccode\u003ekey_escape/1\u003c/code\u003e passes a binary key through \u003ccode\u003ehtml_escape\u003c/code\u003e, which neutralizes quotes and angle brackets but not whitespace, so a key such as \u003ccode\u003ex onmouseover\u003c/code\u003e ends the attribute and starts a new one. An atom key is only dasherized, so any character passes, including quotes and \u003ccode\u003e\u0026gt;\u003c/code\u003e. Nested \u003ccode\u003edata\u003c/code\u003e, \u003ccode\u003earia\u003c/code\u003e and \u003ccode\u003ephx\u003c/code\u003e keys take the same paths. An application is affected when it builds attribute keys from untrusted input and renders them through HEEx \u003ccode\u003e\u0026lt;tag {@attrs}\u0026gt;\u003c/code\u003e, \u003ccode\u003econtent_tag/3\u003c/code\u003e, or a direct call. Before 3.0.0 the same code lived in \u003ccode\u003ePhoenix.HTML.Tag\u003c/code\u003e.\u003c/p\u003e\n\u003cp\u003eThis issue affects phoenix_html: from 1.0.0 before 3.3.5 and from 4.0.0 before 4.4.0.\u003c/p\u003e"
},
{
"base64": false,
"type": "text/markdown",
"value": "Improper Neutralization of Input During Web Page Generation (\u0027Cross-site Scripting\u0027) vulnerability in phoenixframework phoenix_html allows an attacker who controls attribute names to inject attributes and script into rendered HTML via `Phoenix.HTML.attributes_escape/1`.\n\n`attributes_escape/1` in `lib/phoenix_html.ex` escapes attribute values but not attribute names. `key_escape/1` passes a binary key through `html_escape`, which neutralizes quotes and angle brackets but not whitespace, so a key such as `x onmouseover` ends the attribute and starts a new one. An atom key is only dasherized, so any character passes, including quotes and `\u003e`. Nested `data`, `aria` and `phx` keys take the same paths. An application is affected when it builds attribute keys from untrusted input and renders them through HEEx `\u003ctag {@attrs}\u003e`, `content_tag/3`, or a direct call. Before 3.0.0 the same code lived in `Phoenix.HTML.Tag`.\n\nThis issue affects phoenix_html: from 1.0.0 before 3.3.5 and from 4.0.0 before 4.4.0."
}
],
"value": "Improper Neutralization of Input During Web Page Generation (\u0027Cross-site Scripting\u0027) vulnerability in phoenixframework phoenix_html allows an attacker who controls attribute names to inject attributes and script into rendered HTML via Phoenix.HTML.attributes_escape/1.\n\nattributes_escape/1 in lib/phoenix_html.ex escapes attribute values but not attribute names. key_escape/1 passes a binary key through html_escape, which neutralizes quotes and angle brackets but not whitespace, so a key such as x onmouseover ends the attribute and starts a new one. An atom key is only dasherized, so any character passes, including quotes and \u003e. Nested data, aria and phx keys take the same paths. An application is affected when it builds attribute keys from untrusted input and renders them through HEEx \u003ctag {@attrs}\u003e, content_tag/3, or a direct call. Before 3.0.0 the same code lived in Phoenix.HTML.Tag.\n\nThis issue affects phoenix_html: from 1.0.0 before 3.3.5 and from 4.0.0 before 4.4.0."
}
],
"impacts": [
{
"capecId": "CAPEC-63",
"descriptions": [
{
"lang": "en",
"supportingMedia": [
{
"base64": false,
"type": "text/html",
"value": "\u003cp\u003eAn attacker who can influence the attribute names an application renders can add event-handler attributes or close the tag and run script in the browser of every user who views the page.\u003c/p\u003e"
},
{
"base64": false,
"type": "text/markdown",
"value": "An attacker who can influence the attribute names an application renders can add event-handler attributes or close the tag and run script in the browser of every user who views the page."
}
],
"value": "An attacker who can influence the attribute names an application renders can add event-handler attributes or close the tag and run script in the browser of every user who views the page."
}
]
}
],
"metrics": [
{
"cvssV4_0": {
"Automatable": "NOT_DEFINED",
"Recovery": "NOT_DEFINED",
"Safety": "NOT_DEFINED",
"attackComplexity": "LOW",
"attackRequirements": "NONE",
"attackVector": "NETWORK",
"baseScore": 5.3,
"baseSeverity": "MEDIUM",
"privilegesRequired": "NONE",
"providerUrgency": "NOT_DEFINED",
"subAvailabilityImpact": "NONE",
"subConfidentialityImpact": "NONE",
"subIntegrityImpact": "NONE",
"userInteraction": "PASSIVE",
"valueDensity": "NOT_DEFINED",
"vectorString": "CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:P/VC:L/VI:L/VA:N/SC:N/SI:N/SA:N",
"version": "4.0",
"vulnAvailabilityImpact": "NONE",
"vulnConfidentialityImpact": "LOW",
"vulnIntegrityImpact": "LOW",
"vulnerabilityResponseEffort": "NOT_DEFINED"
},
"format": "CVSS",
"scenarios": [
{
"lang": "en",
"value": "GENERAL"
}
]
}
],
"problemTypes": [
{
"descriptions": [
{
"cweId": "CWE-79",
"description": "CWE-79 Improper Neutralization of Input During Web Page Generation (\u0027Cross-site Scripting\u0027)",
"lang": "en",
"type": "CWE"
}
]
}
],
"providerMetadata": {
"dateUpdated": "2026-10-30T20:25:06.758Z",
"orgId": "6b3ad84c-e1a6-4bf7-a703-f496b71e49db",
"shortName": "EEF"
},
"references": [
{
"name": "EEF CNA record for CVE-2026-117645",
"tags": [
"related",
"third-party-advisory"
],
"url": "https://cna.erlef.org/cves/CVE-2026-117645.html"
},
{
"name": "OSV record EEF-CVE-2026-117645",
"tags": [
"related"
],
"url": "https://osv.dev/vulnerability/EEF-CVE-2026-117645"
},
{
"name": "Introducing commit 6ad3a1d in phoenixframework/phoenix_html",
"tags": [
"related"
],
"url": "https://github.com/phoenixframework/phoenix_html/commit/6ad3a1d7960891a468d54636054a98e11dfb5111"
},
{
"name": "Fix commit d9e4e6b in phoenixframework/phoenix_html",
"tags": [
"patch"
],
"url": "https://github.com/phoenixframework/phoenix_html/commit/d9e4e6b2d9be49e25fb6d6b4fdbb2008174f01a6"
},
{
"name": "Fix commit a47dc89 in phoenixframework/phoenix_html",
"tags": [
"patch"
],
"url": "https://github.com/phoenixframework/phoenix_html/commit/a47dc8944a773b7e9273d9691dc6744ec793f1de"
}
],
"source": {
"discovery": "EXTERNAL"
},
"title": "Phoenix.HTML vulnerable to attribute injection via unvalidated attribute names in attributes_escape/1",
"workarounds": [
{
"lang": "en",
"supportingMedia": [
{
"base64": false,
"type": "text/html",
"value": "\u003cp\u003eDo not derive attribute names from untrusted input. Where dynamic attribute keys are needed, check each key against an allow list, or against a pattern such as \u003ccode\u003e^[a-zA-Z][a-zA-Z0-9:._-]*$\u003c/code\u003e, before passing the map to \u003ccode\u003eattributes_escape/1\u003c/code\u003e or a HEEx \u003ccode\u003e{@attrs}\u003c/code\u003e expression. Do not create atoms from request data.\u003c/p\u003e"
},
{
"base64": false,
"type": "text/markdown",
"value": "Do not derive attribute names from untrusted input. Where dynamic attribute keys are needed, check each key against an allow list, or against a pattern such as `^[a-zA-Z][a-zA-Z0-9:._-]*$`, before passing the map to `attributes_escape/1` or a HEEx `{@attrs}` expression. Do not create atoms from request data."
}
],
"value": "Do not derive attribute names from untrusted input. Where dynamic attribute keys are needed, check each key against an allow list, or against a pattern such as ^[a-zA-Z][a-zA-Z0-9:._-]*$, before passing the map to attributes_escape/1 or a HEEx {@attrs} expression. Do not create atoms from request data."
}
],
"x_proofOfConcept": [
{
"lang": "en",
"supportingMedia": [
{
"base64": false,
"type": "text/html",
"value": "\u003col\u003e\n\u003cli\u003eInstall a vulnerable release, for example \u003ccode\u003eMix.install([{:phoenix_html, \"== 4.3.0\"}])\u003c/code\u003e.\u003c/li\u003e\n\u003cli\u003eRender a binary name containing a space: \u003ccode\u003ePhoenix.HTML.attributes_escape(%{\"x onmouseover\" =\u0026gt; \"alert(1)\"}) |\u0026gt; Phoenix.HTML.safe_to_string()\u003c/code\u003e returns \u003ccode\u003e x onmouseover=\"alert(1)\"\u003c/code\u003e.\u003c/li\u003e\n\u003cli\u003eRender an atom name containing a quote: \u003ccode\u003ePhoenix.HTML.attributes_escape([{:\"x\\\" onclick=\\\"alert(1)\", \"y\"}]) |\u0026gt; Phoenix.HTML.safe_to_string()\u003c/code\u003e returns \u003ccode\u003e x\" onclick=\"alert(1)=\"y\"\u003c/code\u003e.\u003c/li\u003e\n\u003cli\u003eRender a nested atom name: \u003ccode\u003ePhoenix.HTML.attributes_escape(aria: [{:\"x\\\"\u0026gt;\u0026lt;script\u0026gt;\", \"y\"}]) |\u0026gt; Phoenix.HTML.safe_to_string()\u003c/code\u003e returns \u003ccode\u003e aria-x\"\u0026gt;\u0026lt;script\u0026gt;=\"y\"\u003c/code\u003e.\u003c/li\u003e\n\u003cli\u003ePlace any of these maps in a HEEx template as \u003ccode\u003e\u0026lt;div {@attrs}\u0026gt;\u003c/code\u003e to deliver the injected attribute to a browser.\u003c/li\u003e\n\u003c/ol\u003e"
},
{
"base64": false,
"type": "text/markdown",
"value": "1. Install a vulnerable release, for example `Mix.install([{:phoenix_html, \"== 4.3.0\"}])`.\n2. Render a binary name containing a space: `Phoenix.HTML.attributes_escape(%{\"x onmouseover\" =\u003e \"alert(1)\"}) |\u003e Phoenix.HTML.safe_to_string()` returns ` x onmouseover=\"alert(1)\"`.\n3. Render an atom name containing a quote: `Phoenix.HTML.attributes_escape([{:\"x\\\" onclick=\\\"alert(1)\", \"y\"}]) |\u003e Phoenix.HTML.safe_to_string()` returns ` x\" onclick=\"alert(1)=\"y\"`.\n4. Render a nested atom name: `Phoenix.HTML.attributes_escape(aria: [{:\"x\\\"\u003e\u003cscript\u003e\", \"y\"}]) |\u003e Phoenix.HTML.safe_to_string()` returns ` aria-x\"\u003e\u003cscript\u003e=\"y\"`.\n5. Place any of these maps in a HEEx template as `\u003cdiv {@attrs}\u003e` to deliver the injected attribute to a browser."
}
],
"value": "* Install a vulnerable release, for example Mix.install([{:phoenix_html, \"== 4.3.0\"}]).\n* Render a binary name containing a space: Phoenix.HTML.attributes_escape(%{\"x onmouseover\" =\u003e \"alert(1)\"}) |\u003e Phoenix.HTML.safe_to_string() returns x onmouseover=\"alert(1)\".\n* Render an atom name containing a quote: Phoenix.HTML.attributes_escape([{:\"x\\\" onclick=\\\"alert(1)\", \"y\"}]) |\u003e Phoenix.HTML.safe_to_string() returns x\" onclick=\"alert(1)=\"y\".\n* Render a nested atom name: Phoenix.HTML.attributes_escape(aria: [{:\"x\\\"\u003e\u003cscript\u003e\", \"y\"}]) |\u003e Phoenix.HTML.safe_to_string() returns aria-x\"\u003e\u003cscript\u003e=\"y\".\n* Place any of these maps in a HEEx template as \u003cdiv {@attrs}\u003e to deliver the injected attribute to a browser."
}
],
"x_technicalAnalysis": [
{
"lang": "en",
"supportingMedia": [
{
"base64": false,
"type": "text/html",
"value": "\u003cp\u003e\u003cstrong\u003e1. Attribute rendering.\u003c/strong\u003e \u003ccode\u003ePhoenix.HTML.attributes_escape/1\u003c/code\u003e walks the key-value pairs through \u003ccode\u003ebuild_attrs/1\u003c/code\u003e and \u003ccode\u003enested_attrs/3\u003c/code\u003e and emits each as \u003ccode\u003ekey_escape(k)=\"attr_escape(v)\"\u003c/code\u003e. Values are escaped. Names are not checked against the HTML attribute-name grammar.\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003e2. Binary names.\u003c/strong\u003e \u003ccode\u003ekey_escape/1\u003c/code\u003e sends a binary through \u003ccode\u003ePhoenix.HTML.Engine.html_escape/1\u003c/code\u003e, which replaces \u003ccode\u003e\u0026amp;\u003c/code\u003e, \u003ccode\u003e\u0026lt;\u003c/code\u003e, \u003ccode\u003e\u0026gt;\u003c/code\u003e, \u003ccode\u003e\"\u003c/code\u003e and \u003ccode\u003e\u0027\u003c/code\u003e. Whitespace passes. HTML uses whitespace to separate attributes, so \u003ccode\u003e%{\"x onmouseover\" =\u0026gt; \"alert(1)\"}\u003c/code\u003e renders \u003ccode\u003e x onmouseover=\"alert(1)\"\u003c/code\u003e: a harmless \u003ccode\u003ex\u003c/code\u003e attribute followed by an attacker-chosen event handler.\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003e3. Atom names.\u003c/strong\u003e \u003ccode\u003ekey_escape/1\u003c/code\u003e converts an atom with \u003ccode\u003eAtom.to_string/1\u003c/code\u003e and replaces \u003ccode\u003e_\u003c/code\u003e with \u003ccode\u003e-\u003c/code\u003e. Nothing is escaped, so \u003ccode\u003e[{:\"x\\\" onclick=\\\"alert(1)\", \"y\"}]\u003c/code\u003e renders \u003ccode\u003e x\" onclick=\"alert(1)=\"y\"\u003c/code\u003e and \u003ccode\u003earia: [{:\"x\\\"\u0026gt;\u0026lt;script\u0026gt;\", \"y\"}]\u003c/code\u003e renders \u003ccode\u003e aria-x\"\u0026gt;\u0026lt;script\u0026gt;=\"y\"\u003c/code\u003e, which leaves the tag entirely. This path needs the application to create atoms from untrusted input, for example \u003ccode\u003eJason.decode!(body, keys: :atoms)\u003c/code\u003e or \u003ccode\u003eString.to_atom/1\u003c/code\u003e on a parameter.\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003e4. Reachability.\u003c/strong\u003e HEEx compiles \u003ccode\u003e\u0026lt;div {@attrs}\u0026gt;\u003c/code\u003e to a call to \u003ccode\u003ePhoenix.HTML.attributes_escape/1\u003c/code\u003e through \u003ccode\u003ePhoenix.LiveView.HTMLEngine\u003c/code\u003e and \u003ccode\u003ePhoenix.LiveView.TagEngine\u003c/code\u003e. \u003ccode\u003etag/2\u003c/code\u003e and \u003ccode\u003econtent_tag/3\u003c/code\u003e in \u003ccode\u003ePhoenix.HTML.Tag\u003c/code\u003e (phoenix_html 1.x to 3.x) and in \u003ccode\u003ePhoenixHTMLHelpers.Tag\u003c/code\u003e (4.x) render attributes the same way. Versions before 3.0.0 emitted the dasherized name without any escaping at all.\u003c/p\u003e"
},
{
"base64": false,
"type": "text/markdown",
"value": "**1. Attribute rendering.** `Phoenix.HTML.attributes_escape/1` walks the key-value pairs through `build_attrs/1` and `nested_attrs/3` and emits each as `key_escape(k)=\"attr_escape(v)\"`. Values are escaped. Names are not checked against the HTML attribute-name grammar.\n\n**2. Binary names.** `key_escape/1` sends a binary through `Phoenix.HTML.Engine.html_escape/1`, which replaces `\u0026`, `\u003c`, `\u003e`, `\"` and `\u0027`. Whitespace passes. HTML uses whitespace to separate attributes, so `%{\"x onmouseover\" =\u003e \"alert(1)\"}` renders ` x onmouseover=\"alert(1)\"`: a harmless `x` attribute followed by an attacker-chosen event handler.\n\n**3. Atom names.** `key_escape/1` converts an atom with `Atom.to_string/1` and replaces `_` with `-`. Nothing is escaped, so `[{:\"x\\\" onclick=\\\"alert(1)\", \"y\"}]` renders ` x\" onclick=\"alert(1)=\"y\"` and `aria: [{:\"x\\\"\u003e\u003cscript\u003e\", \"y\"}]` renders ` aria-x\"\u003e\u003cscript\u003e=\"y\"`, which leaves the tag entirely. This path needs the application to create atoms from untrusted input, for example `Jason.decode!(body, keys: :atoms)` or `String.to_atom/1` on a parameter.\n\n**4. Reachability.** HEEx compiles `\u003cdiv {@attrs}\u003e` to a call to `Phoenix.HTML.attributes_escape/1` through `Phoenix.LiveView.HTMLEngine` and `Phoenix.LiveView.TagEngine`. `tag/2` and `content_tag/3` in `Phoenix.HTML.Tag` (phoenix_html 1.x to 3.x) and in `PhoenixHTMLHelpers.Tag` (4.x) render attributes the same way. Versions before 3.0.0 emitted the dasherized name without any escaping at all."
}
],
"value": "1. Attribute rendering. Phoenix.HTML.attributes_escape/1 walks the key-value pairs through build_attrs/1 and nested_attrs/3 and emits each as key_escape(k)=\"attr_escape(v)\". Values are escaped. Names are not checked against the HTML attribute-name grammar.\n\n2. Binary names. key_escape/1 sends a binary through Phoenix.HTML.Engine.html_escape/1, which replaces \u0026, \u003c, \u003e, \" and \u0027. Whitespace passes. HTML uses whitespace to separate attributes, so %{\"x onmouseover\" =\u003e \"alert(1)\"} renders x onmouseover=\"alert(1)\": a harmless x attribute followed by an attacker-chosen event handler.\n\n3. Atom names. key_escape/1 converts an atom with Atom.to_string/1 and replaces _ with -. Nothing is escaped, so [{:\"x\\\" onclick=\\\"alert(1)\", \"y\"}] renders x\" onclick=\"alert(1)=\"y\" and aria: [{:\"x\\\"\u003e\u003cscript\u003e\", \"y\"}] renders aria-x\"\u003e\u003cscript\u003e=\"y\", which leaves the tag entirely. This path needs the application to create atoms from untrusted input, for example Jason.decode!(body, keys: :atoms) or String.to_atom/1 on a parameter.\n\n4. Reachability. HEEx compiles \u003cdiv {@attrs}\u003e to a call to Phoenix.HTML.attributes_escape/1 through Phoenix.LiveView.HTMLEngine and Phoenix.LiveView.TagEngine. tag/2 and content_tag/3 in Phoenix.HTML.Tag (phoenix_html 1.x to 3.x) and in PhoenixHTMLHelpers.Tag (4.x) render attributes the same way. Versions before 3.0.0 emitted the dasherized name without any escaping at all."
}
]
}
},
"cveMetadata": {
"assignerOrgId": "6b3ad84c-e1a6-4bf7-a703-f496b71e49db",
"assignerShortName": "EEF",
"cveId": "CVE-2026-117645",
"datePublished": "2026-10-30T20:25:06.758Z",
"dateReserved": "2026-10-30T12:15:02.726Z",
"dateUpdated": "2026-11-06T16:02:07.740Z",
"state": "PUBLISHED"
},
"dataType": "CVE_RECORD",
"dataVersion": "5.2"
}
CVE-2026-117666 (GCVE-0-2026-117666)
Vulnerability from cvelistv5 – Published: 2026-10-30 20:22 – Updated: 2026-11-06 16:02
VLAI
EPSS
VEX
Title
ssl_verify_fun: partial-label wildcard certificate matches hostnames across a label boundary
Summary
Improper Validation of Certificate with Host Mismatch vulnerability in deadtrickster ssl_verify_fun allows an adversary in the middle to impersonate a TLS peer via a certificate that carries a partial-label wildcard. try_match_wildcard/4 in src/ssl_verify_hostname.erl anchors only the text before and after the * of an identifier such as f*.example.net, so the wildcard also consumes dots and the certificate is accepted for f.evil.example.net. RFC 6125 confines a wildcard to the left-most label. The same matcher lets *.168.1.1 match the IP literal 192.168.1.1. Whole-label wildcards are matched correctly.
Erlang/OTP 20.0 and later check the hostname in ssl before a user verify_fun sees valid_peer, so a client that keeps server_name_indication set rejects the mismatch regardless. Exposed are clients that disable SNI, servers that verify client certificate hostnames with this library, and direct callers of verify_cert_hostname/2.
This issue affects ssl_verify_fun: from 1.0.1 before 1.1.8.
Severity
SSVC
Exploitation: none
Automatable: no
Technical Impact: total
CISA Coordinator · CISA-ADP (v2.0.3)
Decision recorded 2026-11-06 16:02 UTC
CWE
- CWE-297 - Improper Validation of Certificate with Host Mismatch
Assigner
References
6 references
Impacted products
2 products
| Vendor | Product | Version | |
|---|---|---|---|
| deadtrickster | ssl_verify_fun |
Affected:
1.0.1 , < 1.1.8
(semver)
cpe:2.3:a:deadtrickster:ssl_verify_fun:*:*:*:*:*:*:*:* |
|
| deadtrickster | ssl_verify_fun |
Affected:
a49ca56923356c7a3981692b23c42ef32c2990e0 , < ca34031299cd2e0c98794c0da4ba556b94e39aad
(git)
cpe:2.3:a:deadtrickster:ssl_verify_fun:*:*:*:*:*:*:*:* |
{
"containers": {
"adp": [
{
"metrics": [
{
"other": {
"content": {
"id": "CVE-2026-117666",
"options": [
{
"Exploitation": "none"
},
{
"Automatable": "no"
},
{
"Technical Impact": "total"
}
],
"role": "CISA Coordinator",
"timestamp": "2026-11-06T16:02:35.597989Z",
"version": "2.0.3"
},
"type": "ssvc"
}
}
],
"providerMetadata": {
"dateUpdated": "2026-11-06T16:02:49.781Z",
"orgId": "134c704f-9b21-4f2e-91b3-4a467353bcc0",
"shortName": "CISA-ADP"
},
"title": "CISA ADP Vulnrichment"
}
],
"cna": {
"affected": [
{
"collectionURL": "https://repo.hex.pm",
"cpes": [
"cpe:2.3:a:deadtrickster:ssl_verify_fun:*:*:*:*:*:*:*:*"
],
"defaultStatus": "unaffected",
"modules": [
"ssl_verify_hostname"
],
"packageName": "ssl_verify_fun",
"packageURL": "pkg:hex/ssl_verify_fun",
"product": "ssl_verify_fun",
"programFiles": [
"src/ssl_verify_hostname.erl"
],
"programRoutines": [
{
"name": "ssl_verify_hostname:try_match_wildcard/4"
},
{
"name": "ssl_verify_hostname:try_match_hostname/2"
},
{
"name": "ssl_verify_hostname:verify_cert_hostname/2"
},
{
"name": "ssl_verify_hostname:verify_fun/3"
}
],
"repo": "https://github.com/deadtrickster/ssl_verify_fun.erl",
"vendor": "deadtrickster",
"versions": [
{
"lessThan": "1.1.8",
"status": "affected",
"version": "1.0.1",
"versionType": "semver"
}
]
},
{
"collectionURL": "https://github.com",
"cpes": [
"cpe:2.3:a:deadtrickster:ssl_verify_fun:*:*:*:*:*:*:*:*"
],
"defaultStatus": "unaffected",
"modules": [
"ssl_verify_hostname"
],
"packageName": "deadtrickster/ssl_verify_fun.erl",
"packageURL": "pkg:github/deadtrickster/ssl_verify_fun.erl",
"product": "ssl_verify_fun",
"programFiles": [
"src/ssl_verify_hostname.erl"
],
"programRoutines": [
{
"name": "ssl_verify_hostname:try_match_wildcard/4"
},
{
"name": "ssl_verify_hostname:try_match_hostname/2"
},
{
"name": "ssl_verify_hostname:verify_cert_hostname/2"
},
{
"name": "ssl_verify_hostname:verify_fun/3"
}
],
"repo": "https://github.com/deadtrickster/ssl_verify_fun.erl",
"vendor": "deadtrickster",
"versions": [
{
"lessThan": "ca34031299cd2e0c98794c0da4ba556b94e39aad",
"status": "affected",
"version": "a49ca56923356c7a3981692b23c42ef32c2990e0",
"versionType": "git"
}
]
}
],
"configurations": [
{
"lang": "en",
"supportingMedia": [
{
"base64": false,
"type": "text/html",
"value": "\u003cp\u003essl_verify_fun decides the outcome only where it is the sole hostname check. Erlang/OTP 20.0 and later verify the hostname in \u003ccode\u003essl\u003c/code\u003e before the user \u003ccode\u003everify_fun\u003c/code\u003e is called with \u003ccode\u003evalid_peer\u003c/code\u003e, as long as \u003ccode\u003eserver_name_indication\u003c/code\u003e is a hostname or the connection was upgraded from a socket. A deployment is exposed when it sets \u003ccode\u003e{server_name_indication, disable}\u003c/code\u003e, when a TLS server uses \u003ccode\u003essl_verify_hostname:verify_fun/3\u003c/code\u003e to verify client certificates, when it calls \u003ccode\u003essl_verify_hostname:verify_cert_hostname/2\u003c/code\u003e directly, or when it runs Erlang/OTP older than 20.0. The attacker also needs a certificate with a partial-label wildcard from a CA the client trusts, which the CA/Browser Forum Baseline Requirements do not allow, so the realistic exposure is private PKI.\u003c/p\u003e"
},
{
"base64": false,
"type": "text/markdown",
"value": "ssl_verify_fun decides the outcome only where it is the sole hostname check. Erlang/OTP 20.0 and later verify the hostname in `ssl` before the user `verify_fun` is called with `valid_peer`, as long as `server_name_indication` is a hostname or the connection was upgraded from a socket. A deployment is exposed when it sets `{server_name_indication, disable}`, when a TLS server uses `ssl_verify_hostname:verify_fun/3` to verify client certificates, when it calls `ssl_verify_hostname:verify_cert_hostname/2` directly, or when it runs Erlang/OTP older than 20.0. The attacker also needs a certificate with a partial-label wildcard from a CA the client trusts, which the CA/Browser Forum Baseline Requirements do not allow, so the realistic exposure is private PKI."
}
],
"value": "ssl_verify_fun decides the outcome only where it is the sole hostname check. Erlang/OTP 20.0 and later verify the hostname in ssl before the user verify_fun is called with valid_peer, as long as server_name_indication is a hostname or the connection was upgraded from a socket. A deployment is exposed when it sets {server_name_indication, disable}, when a TLS server uses ssl_verify_hostname:verify_fun/3 to verify client certificates, when it calls ssl_verify_hostname:verify_cert_hostname/2 directly, or when it runs Erlang/OTP older than 20.0. The attacker also needs a certificate with a partial-label wildcard from a CA the client trusts, which the CA/Browser Forum Baseline Requirements do not allow, so the realistic exposure is private PKI."
}
],
"cpeApplicability": [
{
"nodes": [
{
"cpeMatch": [
{
"criteria": "cpe:2.3:a:deadtrickster:ssl_verify_fun:*:*:*:*:*:*:*:*",
"versionEndExcluding": "1.1.8",
"versionStartIncluding": "1.0.1",
"vulnerable": true
}
],
"negate": false,
"operator": "OR"
}
],
"operator": "AND"
}
],
"credits": [
{
"lang": "en",
"type": "finder",
"value": "Andrew Moore"
},
{
"lang": "en",
"type": "finder",
"value": "Martin Karrer"
},
{
"lang": "en",
"type": "reporter",
"value": "Andrew Moore"
},
{
"lang": "en",
"type": "remediation developer",
"value": "Martin Karrer"
},
{
"lang": "en",
"type": "remediation developer",
"value": "deadtrickster"
},
{
"lang": "en",
"type": "coordinator",
"value": "Jonatan M\u00e4nnchen / EEF"
}
],
"dateAssigned": "2026-10-30T10:26:35.000Z",
"descriptions": [
{
"lang": "en",
"supportingMedia": [
{
"base64": false,
"type": "text/html",
"value": "\u003cp\u003eImproper Validation of Certificate with Host Mismatch vulnerability in deadtrickster ssl_verify_fun allows an adversary in the middle to impersonate a TLS peer via a certificate that carries a partial-label wildcard. \u003ccode\u003etry_match_wildcard/4\u003c/code\u003e in \u003ccode\u003esrc/ssl_verify_hostname.erl\u003c/code\u003e anchors only the text before and after the \u003ccode\u003e*\u003c/code\u003e of an identifier such as \u003ccode\u003ef*.example.net\u003c/code\u003e, so the wildcard also consumes dots and the certificate is accepted for \u003ccode\u003ef.evil.example.net\u003c/code\u003e. RFC 6125 confines a wildcard to the left-most label. The same matcher lets \u003ccode\u003e*.168.1.1\u003c/code\u003e match the IP literal \u003ccode\u003e192.168.1.1\u003c/code\u003e. Whole-label wildcards are matched correctly.\u003c/p\u003e\n\u003cp\u003eErlang/OTP 20.0 and later check the hostname in \u003ccode\u003essl\u003c/code\u003e before a user \u003ccode\u003everify_fun\u003c/code\u003e sees \u003ccode\u003evalid_peer\u003c/code\u003e, so a client that keeps \u003ccode\u003eserver_name_indication\u003c/code\u003e set rejects the mismatch regardless. Exposed are clients that disable SNI, servers that verify client certificate hostnames with this library, and direct callers of \u003ccode\u003everify_cert_hostname/2\u003c/code\u003e.\u003c/p\u003e\n\u003cp\u003eThis issue affects ssl_verify_fun: from 1.0.1 before 1.1.8.\u003c/p\u003e"
},
{
"base64": false,
"type": "text/markdown",
"value": "Improper Validation of Certificate with Host Mismatch vulnerability in deadtrickster ssl_verify_fun allows an adversary in the middle to impersonate a TLS peer via a certificate that carries a partial-label wildcard. `try_match_wildcard/4` in `src/ssl_verify_hostname.erl` anchors only the text before and after the `*` of an identifier such as `f*.example.net`, so the wildcard also consumes dots and the certificate is accepted for `f.evil.example.net`. RFC 6125 confines a wildcard to the left-most label. The same matcher lets `*.168.1.1` match the IP literal `192.168.1.1`. Whole-label wildcards are matched correctly.\n\nErlang/OTP 20.0 and later check the hostname in `ssl` before a user `verify_fun` sees `valid_peer`, so a client that keeps `server_name_indication` set rejects the mismatch regardless. Exposed are clients that disable SNI, servers that verify client certificate hostnames with this library, and direct callers of `verify_cert_hostname/2`.\n\nThis issue affects ssl_verify_fun: from 1.0.1 before 1.1.8."
}
],
"value": "Improper Validation of Certificate with Host Mismatch vulnerability in deadtrickster ssl_verify_fun allows an adversary in the middle to impersonate a TLS peer via a certificate that carries a partial-label wildcard. try_match_wildcard/4 in src/ssl_verify_hostname.erl anchors only the text before and after the * of an identifier such as f*.example.net, so the wildcard also consumes dots and the certificate is accepted for f.evil.example.net. RFC 6125 confines a wildcard to the left-most label. The same matcher lets *.168.1.1 match the IP literal 192.168.1.1. Whole-label wildcards are matched correctly.\n\nErlang/OTP 20.0 and later check the hostname in ssl before a user verify_fun sees valid_peer, so a client that keeps server_name_indication set rejects the mismatch regardless. Exposed are clients that disable SNI, servers that verify client certificate hostnames with this library, and direct callers of verify_cert_hostname/2.\n\nThis issue affects ssl_verify_fun: from 1.0.1 before 1.1.8."
}
],
"impacts": [
{
"capecId": "CAPEC-94",
"descriptions": [
{
"lang": "en",
"supportingMedia": [
{
"base64": false,
"type": "text/html",
"value": "\u003cp\u003eA holder of a certificate for \u003ccode\u003ef*.example.net\u003c/code\u003e from a CA the client trusts can impersonate any host named \u003ccode\u003ef.\u0026lt;label\u0026gt;.example.net\u003c/code\u003e to a client that relies on ssl_verify_fun alone for hostname verification, and read or alter the traffic of that connection.\u003c/p\u003e"
},
{
"base64": false,
"type": "text/markdown",
"value": "A holder of a certificate for `f*.example.net` from a CA the client trusts can impersonate any host named `f.\u003clabel\u003e.example.net` to a client that relies on ssl_verify_fun alone for hostname verification, and read or alter the traffic of that connection."
}
],
"value": "A holder of a certificate for f*.example.net from a CA the client trusts can impersonate any host named f.\u003clabel\u003e.example.net to a client that relies on ssl_verify_fun alone for hostname verification, and read or alter the traffic of that connection."
}
]
}
],
"metrics": [
{
"cvssV4_0": {
"Automatable": "NOT_DEFINED",
"Recovery": "NOT_DEFINED",
"Safety": "NOT_DEFINED",
"attackComplexity": "HIGH",
"attackRequirements": "PRESENT",
"attackVector": "NETWORK",
"baseScore": 9.1,
"baseSeverity": "CRITICAL",
"privilegesRequired": "NONE",
"providerUrgency": "NOT_DEFINED",
"subAvailabilityImpact": "NONE",
"subConfidentialityImpact": "NONE",
"subIntegrityImpact": "NONE",
"userInteraction": "NONE",
"valueDensity": "NOT_DEFINED",
"vectorString": "CVSS:4.0/AV:N/AC:H/AT:P/PR:N/UI:N/VC:H/VI:H/VA:N/SC:N/SI:N/SA:N",
"version": "4.0",
"vulnAvailabilityImpact": "NONE",
"vulnConfidentialityImpact": "HIGH",
"vulnIntegrityImpact": "HIGH",
"vulnerabilityResponseEffort": "NOT_DEFINED"
},
"format": "CVSS",
"scenarios": [
{
"lang": "en",
"value": "GENERAL"
}
]
}
],
"problemTypes": [
{
"descriptions": [
{
"cweId": "CWE-297",
"description": "CWE-297 Improper Validation of Certificate with Host Mismatch",
"lang": "en",
"type": "CWE"
}
]
}
],
"providerMetadata": {
"dateUpdated": "2026-10-30T20:22:16.466Z",
"orgId": "6b3ad84c-e1a6-4bf7-a703-f496b71e49db",
"shortName": "EEF"
},
"references": [
{
"name": "ssl_verify_fun: partial-label wildcard certificate matches hostnames across a label boundary",
"tags": [
"vendor-advisory",
"related"
],
"url": "https://github.com/deadtrickster/ssl_verify_fun.erl/security/advisories/GHSA-7gw2-hphm-c5px"
},
{
"name": "EEF CNA record for CVE-2026-117666",
"tags": [
"related"
],
"url": "https://cna.erlef.org/cves/CVE-2026-117666.html"
},
{
"name": "OSV record EEF-CVE-2026-117666",
"tags": [
"related"
],
"url": "https://osv.dev/vulnerability/EEF-CVE-2026-117666"
},
{
"name": "Do not let partial-label wildcards match across label boundaries (ssl_verify_fun.erl pull request #33)",
"tags": [
"patch"
],
"url": "https://github.com/deadtrickster/ssl_verify_fun.erl/pull/33"
},
{
"name": "Introducing commit a49ca56 in deadtrickster/ssl_verify_fun.erl",
"tags": [
"related"
],
"url": "https://github.com/deadtrickster/ssl_verify_fun.erl/commit/a49ca56923356c7a3981692b23c42ef32c2990e0"
},
{
"name": "Fix commit ca34031 in deadtrickster/ssl_verify_fun.erl",
"tags": [
"patch"
],
"url": "https://github.com/deadtrickster/ssl_verify_fun.erl/commit/ca34031299cd2e0c98794c0da4ba556b94e39aad"
}
],
"source": {
"discovery": "EXTERNAL"
},
"title": "ssl_verify_fun: partial-label wildcard certificate matches hostnames across a label boundary",
"workarounds": [
{
"lang": "en",
"supportingMedia": [
{
"base64": false,
"type": "text/html",
"value": "\u003cp\u003eOn Erlang/OTP 21 or later, keep \u003ccode\u003eserver_name_indication\u003c/code\u003e set to the target hostname and pass \u003ccode\u003e{customize_hostname_check, [{match_fun, public_key:pkix_verify_hostname_match_fun(https)}]}\u003c/code\u003e in the \u003ccode\u003essl\u003c/code\u003e options. The hostname check in \u003ccode\u003essl\u003c/code\u003e then rejects the mismatch before ssl_verify_fun runs, and legitimate wildcard certificates continue to match. hackney and tls_certificate_check already set both options.\u003c/p\u003e"
},
{
"base64": false,
"type": "text/markdown",
"value": "On Erlang/OTP 21 or later, keep `server_name_indication` set to the target hostname and pass `{customize_hostname_check, [{match_fun, public_key:pkix_verify_hostname_match_fun(https)}]}` in the `ssl` options. The hostname check in `ssl` then rejects the mismatch before ssl_verify_fun runs, and legitimate wildcard certificates continue to match. hackney and tls_certificate_check already set both options."
}
],
"value": "On Erlang/OTP 21 or later, keep server_name_indication set to the target hostname and pass {customize_hostname_check, [{match_fun, public_key:pkix_verify_hostname_match_fun(https)}]} in the ssl options. The hostname check in ssl then rejects the mismatch before ssl_verify_fun runs, and legitimate wildcard certificates continue to match. hackney and tls_certificate_check already set both options."
}
],
"x_proofOfConcept": [
{
"lang": "en",
"supportingMedia": [
{
"base64": false,
"type": "text/html",
"value": "\u003col\u003e\n\u003cli\u003eIssue a leaf certificate whose only \u003ccode\u003esubjectAltName\u003c/code\u003e entry is \u003ccode\u003edNSName\u003c/code\u003e \u003ccode\u003ef*.example.net\u003c/code\u003e, for example with \u003ccode\u003epublic_key:pkix_test_data/1\u003c/code\u003e, and decode it with \u003ccode\u003epublic_key:pkix_decode_cert(Der, otp)\u003c/code\u003e.\u003c/li\u003e\n\u003cli\u003eCall \u003ccode\u003essl_verify_hostname:verify_cert_hostname(Cert, \"foo.example.net\")\u003c/code\u003e. It returns \u003ccode\u003e{valid, \"foo.example.net\"}\u003c/code\u003e, as expected.\u003c/li\u003e\n\u003cli\u003eCall \u003ccode\u003essl_verify_hostname:verify_cert_hostname(Cert, \"f.evil.example.net\")\u003c/code\u003e. It returns \u003ccode\u003e{valid, \"f.evil.example.net\"}\u003c/code\u003e although the hostname spans two labels where the wildcard sits.\u003c/li\u003e\n\u003cli\u003eCall \u003ccode\u003essl_verify_hostname:verify_cert_hostname(Cert, \"a.b.example.net\")\u003c/code\u003e as a control. It returns \u003ccode\u003e{fail, unable_to_match_altnames}\u003c/code\u003e.\u003c/li\u003e\n\u003cli\u003eFor the IP-literal variant, issue a leaf with \u003ccode\u003edNSName\u003c/code\u003e \u003ccode\u003e*.168.1.1\u003c/code\u003e and call \u003ccode\u003everify_cert_hostname(Cert, \"192.168.1.1\")\u003c/code\u003e. It returns \u003ccode\u003e{valid, \"192.168.1.1\"}\u003c/code\u003e.\u003c/li\u003e\n\u003cli\u003eTo observe the full TLS path, serve the first certificate from \u003ccode\u003essl:listen/2\u003c/code\u003e and connect with \u003ccode\u003e{verify, verify_peer}\u003c/code\u003e, the CA in \u003ccode\u003ecacerts\u003c/code\u003e, \u003ccode\u003e{verify_fun, {fun ssl_verify_hostname:verify_fun/3, [{check_hostname, \"f.evil.example.net\"}]}}\u003c/code\u003e and \u003ccode\u003e{server_name_indication, disable}\u003c/code\u003e. The handshake succeeds. With \u003ccode\u003e{server_name_indication, \"f.evil.example.net\"}\u003c/code\u003e instead, OTP 20.0 or later rejects the certificate with \u003ccode\u003ehostname_check_failed\u003c/code\u003e before ssl_verify_fun runs.\u003c/li\u003e\n\u003c/ol\u003e"
},
{
"base64": false,
"type": "text/markdown",
"value": "1. Issue a leaf certificate whose only `subjectAltName` entry is `dNSName` `f*.example.net`, for example with `public_key:pkix_test_data/1`, and decode it with `public_key:pkix_decode_cert(Der, otp)`.\n2. Call `ssl_verify_hostname:verify_cert_hostname(Cert, \"foo.example.net\")`. It returns `{valid, \"foo.example.net\"}`, as expected.\n3. Call `ssl_verify_hostname:verify_cert_hostname(Cert, \"f.evil.example.net\")`. It returns `{valid, \"f.evil.example.net\"}` although the hostname spans two labels where the wildcard sits.\n4. Call `ssl_verify_hostname:verify_cert_hostname(Cert, \"a.b.example.net\")` as a control. It returns `{fail, unable_to_match_altnames}`.\n5. For the IP-literal variant, issue a leaf with `dNSName` `*.168.1.1` and call `verify_cert_hostname(Cert, \"192.168.1.1\")`. It returns `{valid, \"192.168.1.1\"}`.\n6. To observe the full TLS path, serve the first certificate from `ssl:listen/2` and connect with `{verify, verify_peer}`, the CA in `cacerts`, `{verify_fun, {fun ssl_verify_hostname:verify_fun/3, [{check_hostname, \"f.evil.example.net\"}]}}` and `{server_name_indication, disable}`. The handshake succeeds. With `{server_name_indication, \"f.evil.example.net\"}` instead, OTP 20.0 or later rejects the certificate with `hostname_check_failed` before ssl_verify_fun runs."
}
],
"value": "* Issue a leaf certificate whose only subjectAltName entry is dNSName f*.example.net, for example with public_key:pkix_test_data/1, and decode it with public_key:pkix_decode_cert(Der, otp).\n* Call ssl_verify_hostname:verify_cert_hostname(Cert, \"foo.example.net\"). It returns {valid, \"foo.example.net\"}, as expected.\n* Call ssl_verify_hostname:verify_cert_hostname(Cert, \"f.evil.example.net\"). It returns {valid, \"f.evil.example.net\"} although the hostname spans two labels where the wildcard sits.\n* Call ssl_verify_hostname:verify_cert_hostname(Cert, \"a.b.example.net\") as a control. It returns {fail, unable_to_match_altnames}.\n* For the IP-literal variant, issue a leaf with dNSName *.168.1.1 and call verify_cert_hostname(Cert, \"192.168.1.1\"). It returns {valid, \"192.168.1.1\"}.\n* To observe the full TLS path, serve the first certificate from ssl:listen/2 and connect with {verify, verify_peer}, the CA in cacerts, {verify_fun, {fun ssl_verify_hostname:verify_fun/3, [{check_hostname, \"f.evil.example.net\"}]}} and {server_name_indication, disable}. The handshake succeeds. With {server_name_indication, \"f.evil.example.net\"} instead, OTP 20.0 or later rejects the certificate with hostname_check_failed before ssl_verify_fun runs."
}
],
"x_technicalAnalysis": [
{
"lang": "en",
"supportingMedia": [
{
"base64": false,
"type": "text/html",
"value": "\u003cp\u003e\u003cstrong\u003e1. Identifier parsing.\u003c/strong\u003e \u003ccode\u003essl_verify_hostname:try_match_hostname/2\u003c/code\u003e strips trailing dots, tries an exact case-insensitive match, and otherwise calls \u003ccode\u003eparse_and_validate_wildcard_identifier/2\u003c/code\u003e. That function accepts an identifier with one \u003ccode\u003e*\u003c/code\u003e in the left-most label and at least two labels after it, and classifies it as \u003ccode\u003eSingleCharW\u003c/code\u003e when the label is exactly \u003ccode\u003e*\u003c/code\u003e.\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003e2. Whole-label wildcard, correct.\u003c/strong\u003e For \u003ccode\u003e*.example.net\u003c/code\u003e the matcher compares the text after the \u003ccode\u003e*\u003c/code\u003e with the hostname from its first dot onward, so \u003ccode\u003ea.b.example.net\u003c/code\u003e fails because \u003ccode\u003e.b.example.net\u003c/code\u003e differs from \u003ccode\u003e.example.net\u003c/code\u003e.\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003e3. Partial-label wildcard, incorrect.\u003c/strong\u003e For \u003ccode\u003ef*.example.net\u003c/code\u003e the \u003ccode\u003eSingleCharW\u003c/code\u003e branch is skipped. \u003ccode\u003etry_match_wildcard/4\u003c/code\u003e then checks only that the hostname ends with \u003ccode\u003e.example.net\u003c/code\u003e and starts with \u003ccode\u003ef\u003c/code\u003e. Nothing checks the span consumed by the \u003ccode\u003e*\u003c/code\u003e, so \u003ccode\u003ef.evil.example.net\u003c/code\u003e and \u003ccode\u003ef.a.b.example.net\u003c/code\u003e pass. RFC 6125 section 6.4.3 limits a wildcard to the left-most label. The \u003ccode\u003ewildcard_not_in_a_label/2\u003c/code\u003e guard only rejects internationalized \u003ccode\u003exn--\u003c/code\u003e labels and does not help.\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003e4. IP literals.\u003c/strong\u003e The reference identifier is treated as a DNS name whatever its shape, so \u003ccode\u003everify_cert_hostname/2\u003c/code\u003e with hostname \u003ccode\u003e192.168.1.1\u003c/code\u003e matches a \u003ccode\u003edNSName\u003c/code\u003e of \u003ccode\u003e*.168.1.1\u003c/code\u003e through the whole-label branch. RFC 6125 does not apply wildcard matching to IP addresses, and OTP\u0027s \u003ccode\u003essl\u003c/code\u003e converts an IP-literal string to an \u003ccode\u003e{ip, ...}\u003c/code\u003e reference that only matches \u003ccode\u003eiPAddress\u003c/code\u003e entries.\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003e5. Interaction with Erlang/OTP.\u003c/strong\u003e When \u003ccode\u003essl_verify_hostname:verify_fun/3\u003c/code\u003e is used as \u003ccode\u003everify_fun\u003c/code\u003e, OTP 20.0 and later call \u003ccode\u003essl_certificate:validate/4\u003c/code\u003e for \u003ccode\u003evalid_peer\u003c/code\u003e before the user fun. In the client role with \u003ccode\u003eserver_name_indication\u003c/code\u003e set to a hostname, or with a peer IP from a socket upgrade, OTP runs \u003ccode\u003epublic_key:pkix_verify_hostname/3\u003c/code\u003e and hands the user fun \u003ccode\u003e{bad_cert, hostname_check_failed}\u003c/code\u003e instead of \u003ccode\u003evalid_peer\u003c/code\u003e, which ssl_verify_fun turns into \u003ccode\u003e{fail, ...}\u003c/code\u003e. OTP\u0027s default match function allows no wildcards, and its \u003ccode\u003ehttps\u003c/code\u003e match function (\u003ccode\u003epublic_key:pkix_verify_hostname_match_fun/1\u003c/code\u003e) splits both names on dots and matches the wildcard within the first label only. The ssl_verify_fun result therefore decides the outcome only with \u003ccode\u003e{server_name_indication, disable}\u003c/code\u003e, in the server role, on direct calls to \u003ccode\u003everify_cert_hostname/2\u003c/code\u003e, or on OTP older than 20.0.\u003c/p\u003e"
},
{
"base64": false,
"type": "text/markdown",
"value": "**1. Identifier parsing.** `ssl_verify_hostname:try_match_hostname/2` strips trailing dots, tries an exact case-insensitive match, and otherwise calls `parse_and_validate_wildcard_identifier/2`. That function accepts an identifier with one `*` in the left-most label and at least two labels after it, and classifies it as `SingleCharW` when the label is exactly `*`.\n\n**2. Whole-label wildcard, correct.** For `*.example.net` the matcher compares the text after the `*` with the hostname from its first dot onward, so `a.b.example.net` fails because `.b.example.net` differs from `.example.net`.\n\n**3. Partial-label wildcard, incorrect.** For `f*.example.net` the `SingleCharW` branch is skipped. `try_match_wildcard/4` then checks only that the hostname ends with `.example.net` and starts with `f`. Nothing checks the span consumed by the `*`, so `f.evil.example.net` and `f.a.b.example.net` pass. RFC 6125 section 6.4.3 limits a wildcard to the left-most label. The `wildcard_not_in_a_label/2` guard only rejects internationalized `xn--` labels and does not help.\n\n**4. IP literals.** The reference identifier is treated as a DNS name whatever its shape, so `verify_cert_hostname/2` with hostname `192.168.1.1` matches a `dNSName` of `*.168.1.1` through the whole-label branch. RFC 6125 does not apply wildcard matching to IP addresses, and OTP\u0027s `ssl` converts an IP-literal string to an `{ip, ...}` reference that only matches `iPAddress` entries.\n\n**5. Interaction with Erlang/OTP.** When `ssl_verify_hostname:verify_fun/3` is used as `verify_fun`, OTP 20.0 and later call `ssl_certificate:validate/4` for `valid_peer` before the user fun. In the client role with `server_name_indication` set to a hostname, or with a peer IP from a socket upgrade, OTP runs `public_key:pkix_verify_hostname/3` and hands the user fun `{bad_cert, hostname_check_failed}` instead of `valid_peer`, which ssl_verify_fun turns into `{fail, ...}`. OTP\u0027s default match function allows no wildcards, and its `https` match function (`public_key:pkix_verify_hostname_match_fun/1`) splits both names on dots and matches the wildcard within the first label only. The ssl_verify_fun result therefore decides the outcome only with `{server_name_indication, disable}`, in the server role, on direct calls to `verify_cert_hostname/2`, or on OTP older than 20.0."
}
],
"value": "1. Identifier parsing. ssl_verify_hostname:try_match_hostname/2 strips trailing dots, tries an exact case-insensitive match, and otherwise calls parse_and_validate_wildcard_identifier/2. That function accepts an identifier with one * in the left-most label and at least two labels after it, and classifies it as SingleCharW when the label is exactly *.\n\n2. Whole-label wildcard, correct. For *.example.net the matcher compares the text after the * with the hostname from its first dot onward, so a.b.example.net fails because .b.example.net differs from .example.net.\n\n3. Partial-label wildcard, incorrect. For f*.example.net the SingleCharW branch is skipped. try_match_wildcard/4 then checks only that the hostname ends with .example.net and starts with f. Nothing checks the span consumed by the *, so f.evil.example.net and f.a.b.example.net pass. RFC 6125 section 6.4.3 limits a wildcard to the left-most label. The wildcard_not_in_a_label/2 guard only rejects internationalized xn-- labels and does not help.\n\n4. IP literals. The reference identifier is treated as a DNS name whatever its shape, so verify_cert_hostname/2 with hostname 192.168.1.1 matches a dNSName of *.168.1.1 through the whole-label branch. RFC 6125 does not apply wildcard matching to IP addresses, and OTP\u0027s ssl converts an IP-literal string to an {ip, ...} reference that only matches iPAddress entries.\n\n5. Interaction with Erlang/OTP. When ssl_verify_hostname:verify_fun/3 is used as verify_fun, OTP 20.0 and later call ssl_certificate:validate/4 for valid_peer before the user fun. In the client role with server_name_indication set to a hostname, or with a peer IP from a socket upgrade, OTP runs public_key:pkix_verify_hostname/3 and hands the user fun {bad_cert, hostname_check_failed} instead of valid_peer, which ssl_verify_fun turns into {fail, ...}. OTP\u0027s default match function allows no wildcards, and its https match function (public_key:pkix_verify_hostname_match_fun/1) splits both names on dots and matches the wildcard within the first label only. The ssl_verify_fun result therefore decides the outcome only with {server_name_indication, disable}, in the server role, on direct calls to verify_cert_hostname/2, or on OTP older than 20.0."
}
]
}
},
"cveMetadata": {
"assignerOrgId": "6b3ad84c-e1a6-4bf7-a703-f496b71e49db",
"assignerShortName": "EEF",
"cveId": "CVE-2026-117666",
"datePublished": "2026-10-30T20:22:16.466Z",
"dateReserved": "2026-10-30T10:00:01.681Z",
"dateUpdated": "2026-11-06T16:02:49.781Z",
"state": "PUBLISHED"
},
"dataType": "CVE_RECORD",
"dataVersion": "5.2"
}
CVE-2026-97209 (GCVE-0-2026-97209)
Vulnerability from cvelistv5 – Published: 2026-10-30 08:59 – Updated: 2026-10-30 16:50
VLAI
EPSS
VEX
Title
Insecure temporary file in mix deps.partition lets a local user execute code in another user's dependency build
Summary
Insecure Temporary File vulnerability in Mix deps.partition in Elixir allows a local user to execute code as another user via a swapped partition config file. Partitioned dependency compilation passes the project configuration to each partition VM through a file in a fixed-name directory under the shared system temporary directory, and the partition trusts what it reads back. Another local user on the same host can exploit this during a mix deps.compile run that uses partitioned compilation.
Mix.Tasks.Deps.Partition.server/4 in lib/mix/lib/mix/tasks/deps.partition.ex creates the directory with File.mkdir_p/1, which accepts an existing directory without checking its owner, so a user who created it first can replace the file before client/0 reads it.
This issue affects mix: from 1.20.0-rc.4 before 1.20.5.
Severity
SSVC
Exploitation: poc
Automatable: no
Technical Impact: total
CISA Coordinator · CISA-ADP (v2.0.3)
Decision recorded 2026-10-30 16:50 UTC
CWE
- CWE-377 - Insecure Temporary File
Assigner
References
6 references
Impacted products
2 products
| Vendor | Product | Version | |
|---|---|---|---|
| elixir-lang | elixir |
Affected:
1.20.0-rc.4 , < 1.20.5
(semver)
cpe:2.3:a:elixir-lang:elixir:*:*:*:*:*:*:*:* |
|
| elixir-lang | elixir |
Affected:
7f3d95b60c28ae3026a5c1bf943166391245bfd1 , < *
(git)
cpe:2.3:a:elixir-lang:elixir:*:*:*:*:*:*:*:* |
Date Public
2026-10-30 08:59
{
"containers": {
"adp": [
{
"metrics": [
{
"other": {
"content": {
"id": "CVE-2026-97209",
"options": [
{
"Exploitation": "poc"
},
{
"Automatable": "no"
},
{
"Technical Impact": "total"
}
],
"role": "CISA Coordinator",
"timestamp": "2026-10-30T16:50:17.250968Z",
"version": "2.0.3"
},
"type": "ssvc"
}
}
],
"providerMetadata": {
"dateUpdated": "2026-10-30T16:50:46.465Z",
"orgId": "134c704f-9b21-4f2e-91b3-4a467353bcc0",
"shortName": "CISA-ADP"
},
"references": [
{
"tags": [
"exploit"
],
"url": "https://github.com/elixir-lang/elixir/security/advisories/GHSA-96q6-xhwp-fc8q"
}
],
"title": "CISA ADP Vulnrichment"
}
],
"cna": {
"affected": [
{
"cpes": [
"cpe:2.3:a:elixir-lang:elixir:*:*:*:*:*:*:*:*"
],
"defaultStatus": "unaffected",
"modules": [
"\u0027Elixir.Mix.Tasks.Deps.Partition\u0027"
],
"packageName": "mix",
"packageURL": "pkg:otp/mix?repository_url=https:%2F%2Fgithub.com%2Felixir-lang%2Felixir\u0026vcs_url=git%2Bhttps:%2F%2Fgithub.com%2Felixir-lang%2Felixir.git",
"product": "elixir",
"programFiles": [
"lib/mix/tasks/deps.partition.ex"
],
"programRoutines": [
{
"name": "\u0027Elixir.Mix.Tasks.Deps.Partition\u0027:server/3"
},
{
"name": "\u0027Elixir.Mix.Tasks.Deps.Partition\u0027:server/4"
},
{
"name": "\u0027Elixir.Mix.Tasks.Deps.Partition\u0027:client/0"
}
],
"repo": "https://github.com/elixir-lang/elixir",
"vendor": "elixir-lang",
"versions": [
{
"lessThan": "1.20.5",
"status": "affected",
"version": "1.20.0-rc.4",
"versionType": "semver"
}
]
},
{
"collectionURL": "https://github.com",
"cpes": [
"cpe:2.3:a:elixir-lang:elixir:*:*:*:*:*:*:*:*"
],
"defaultStatus": "unaffected",
"modules": [
"\u0027Elixir.Mix.Tasks.Deps.Partition\u0027"
],
"packageName": "elixir-lang/elixir",
"packageURL": "pkg:github/elixir-lang/elixir",
"product": "elixir",
"programFiles": [
"lib/mix/lib/mix/tasks/deps.partition.ex"
],
"programRoutines": [
{
"name": "\u0027Elixir.Mix.Tasks.Deps.Partition\u0027:server/3"
},
{
"name": "\u0027Elixir.Mix.Tasks.Deps.Partition\u0027:server/4"
},
{
"name": "\u0027Elixir.Mix.Tasks.Deps.Partition\u0027:client/0"
}
],
"repo": "https://github.com/elixir-lang/elixir",
"vendor": "elixir-lang",
"versions": [
{
"changes": [
{
"at": "55eb91220a1de14dc5aa27029d1f45b0f5832b2d",
"status": "unaffected"
},
{
"at": "9adbe955c8664f4be77d879dd38c003dd39fa777",
"status": "unaffected"
}
],
"lessThan": "*",
"status": "affected",
"version": "7f3d95b60c28ae3026a5c1bf943166391245bfd1",
"versionType": "git"
}
]
}
],
"configurations": [
{
"lang": "en",
"supportingMedia": [
{
"base64": false,
"type": "text/html",
"value": "\u003cp\u003eThe attack requires partitioned dependency compilation, which is off by default and enabled by setting \u003ccode\u003eMIX_OS_DEPS_COMPILE_PARTITION_COUNT\u003c/code\u003e to a value greater than one, a \u003ccode\u003emix deps.compile\u003c/code\u003e run that compiles at least two non-local dependencies, and a system temporary directory shared with other local users, which is the default on Linux when \u003ccode\u003eTMPDIR\u003c/code\u003e is unset. Platforms with per-user temporary directories, such as macOS and Windows by default, are not affected unless \u003ccode\u003eTMPDIR\u003c/code\u003e points at a shared location.\u003c/p\u003e"
},
{
"base64": false,
"type": "text/markdown",
"value": "The attack requires partitioned dependency compilation, which is off by default and enabled by setting `MIX_OS_DEPS_COMPILE_PARTITION_COUNT` to a value greater than one, a `mix deps.compile` run that compiles at least two non-local dependencies, and a system temporary directory shared with other local users, which is the default on Linux when `TMPDIR` is unset. Platforms with per-user temporary directories, such as macOS and Windows by default, are not affected unless `TMPDIR` points at a shared location."
}
],
"value": "The attack requires partitioned dependency compilation, which is off by default and enabled by setting MIX_OS_DEPS_COMPILE_PARTITION_COUNT to a value greater than one, a mix deps.compile run that compiles at least two non-local dependencies, and a system temporary directory shared with other local users, which is the default on Linux when TMPDIR is unset. Platforms with per-user temporary directories, such as macOS and Windows by default, are not affected unless TMPDIR points at a shared location."
}
],
"cpeApplicability": [
{
"nodes": [
{
"cpeMatch": [
{
"criteria": "cpe:2.3:a:elixir-lang:elixir:*:*:*:*:*:*:*:*",
"versionEndExcluding": "1.20.5",
"versionStartIncluding": "1.20.0-rc.4",
"vulnerable": true
}
],
"negate": false,
"operator": "OR"
}
],
"operator": "AND"
}
],
"credits": [
{
"lang": "en",
"type": "finder",
"value": "Quinn Wilton"
},
{
"lang": "en",
"type": "reporter",
"value": "Quinn Wilton"
},
{
"lang": "en",
"type": "remediation developer",
"value": "Jos\u00e9 Valim"
},
{
"lang": "en",
"type": "coordinator",
"value": "Jonatan M\u00e4nnchen / EEF"
}
],
"dateAssigned": "2026-10-15T10:43:20.000Z",
"datePublic": "2026-10-30T08:59:17.000Z",
"descriptions": [
{
"lang": "en",
"supportingMedia": [
{
"base64": false,
"type": "text/html",
"value": "\u003cp\u003eInsecure Temporary File vulnerability in Mix \u003ccode\u003edeps.partition\u003c/code\u003e in Elixir allows a local user to execute code as another user via a swapped partition config file. Partitioned dependency compilation passes the project configuration to each partition VM through a file in a fixed-name directory under the shared system temporary directory, and the partition trusts what it reads back. Another local user on the same host can exploit this during a \u003ccode\u003emix deps.compile\u003c/code\u003e run that uses partitioned compilation.\u003c/p\u003e\n\u003cp\u003e\u003ccode\u003eMix.Tasks.Deps.Partition.server/4\u003c/code\u003e in \u003ccode\u003elib/mix/lib/mix/tasks/deps.partition.ex\u003c/code\u003e creates the directory with \u003ccode\u003eFile.mkdir_p/1\u003c/code\u003e, which accepts an existing directory without checking its owner, so a user who created it first can replace the file before \u003ccode\u003eclient/0\u003c/code\u003e reads it.\u003c/p\u003e\n\u003cp\u003eThis issue affects mix: from 1.20.0-rc.4 before 1.20.5.\u003c/p\u003e"
},
{
"base64": false,
"type": "text/markdown",
"value": "Insecure Temporary File vulnerability in Mix `deps.partition` in Elixir allows a local user to execute code as another user via a swapped partition config file. Partitioned dependency compilation passes the project configuration to each partition VM through a file in a fixed-name directory under the shared system temporary directory, and the partition trusts what it reads back. Another local user on the same host can exploit this during a `mix deps.compile` run that uses partitioned compilation.\n\n`Mix.Tasks.Deps.Partition.server/4` in `lib/mix/lib/mix/tasks/deps.partition.ex` creates the directory with `File.mkdir_p/1`, which accepts an existing directory without checking its owner, so a user who created it first can replace the file before `client/0` reads it.\n\nThis issue affects mix: from 1.20.0-rc.4 before 1.20.5."
}
],
"value": "Insecure Temporary File vulnerability in Mix deps.partition in Elixir allows a local user to execute code as another user via a swapped partition config file. Partitioned dependency compilation passes the project configuration to each partition VM through a file in a fixed-name directory under the shared system temporary directory, and the partition trusts what it reads back. Another local user on the same host can exploit this during a mix deps.compile run that uses partitioned compilation.\n\nMix.Tasks.Deps.Partition.server/4 in lib/mix/lib/mix/tasks/deps.partition.ex creates the directory with File.mkdir_p/1, which accepts an existing directory without checking its owner, so a user who created it first can replace the file before client/0 reads it.\n\nThis issue affects mix: from 1.20.0-rc.4 before 1.20.5."
}
],
"impacts": [
{
"capecId": "CAPEC-26",
"descriptions": [
{
"lang": "en",
"supportingMedia": [
{
"base64": false,
"type": "text/html",
"value": "\u003cp\u003eA local user on a shared host who creates the partition directory before the victim\u0027s build replaces the serialized configuration and runs arbitrary shell commands as the developer or CI user whose \u003ccode\u003emix deps.compile\u003c/code\u003e uses partitioned compilation.\u003c/p\u003e"
},
{
"base64": false,
"type": "text/markdown",
"value": "A local user on a shared host who creates the partition directory before the victim\u0027s build replaces the serialized configuration and runs arbitrary shell commands as the developer or CI user whose `mix deps.compile` uses partitioned compilation."
}
],
"value": "A local user on a shared host who creates the partition directory before the victim\u0027s build replaces the serialized configuration and runs arbitrary shell commands as the developer or CI user whose mix deps.compile uses partitioned compilation."
}
]
},
{
"capecId": "CAPEC-155",
"descriptions": [
{
"lang": "en",
"supportingMedia": [
{
"base64": false,
"type": "text/html",
"value": "\u003cp\u003eEven without pre-creating the directory, the world-readable partition file discloses the project configuration, including \u003ccode\u003eMix.install\u003c/code\u003e compile-time config, to other local users while the build runs.\u003c/p\u003e"
},
{
"base64": false,
"type": "text/markdown",
"value": "Even without pre-creating the directory, the world-readable partition file discloses the project configuration, including `Mix.install` compile-time config, to other local users while the build runs."
}
],
"value": "Even without pre-creating the directory, the world-readable partition file discloses the project configuration, including Mix.install compile-time config, to other local users while the build runs."
}
]
}
],
"metrics": [
{
"cvssV4_0": {
"Automatable": "NOT_DEFINED",
"Recovery": "NOT_DEFINED",
"Safety": "NOT_DEFINED",
"attackComplexity": "LOW",
"attackRequirements": "PRESENT",
"attackVector": "LOCAL",
"baseScore": 5.4,
"baseSeverity": "MEDIUM",
"privilegesRequired": "LOW",
"providerUrgency": "NOT_DEFINED",
"subAvailabilityImpact": "NONE",
"subConfidentialityImpact": "NONE",
"subIntegrityImpact": "NONE",
"userInteraction": "PASSIVE",
"valueDensity": "NOT_DEFINED",
"vectorString": "CVSS:4.0/AV:L/AC:L/AT:P/PR:L/UI:P/VC:H/VI:H/VA:H/SC:N/SI:N/SA:N",
"version": "4.0",
"vulnAvailabilityImpact": "HIGH",
"vulnConfidentialityImpact": "HIGH",
"vulnIntegrityImpact": "HIGH",
"vulnerabilityResponseEffort": "NOT_DEFINED"
},
"format": "CVSS",
"scenarios": [
{
"lang": "en",
"value": "GENERAL"
}
]
}
],
"problemTypes": [
{
"descriptions": [
{
"cweId": "CWE-377",
"description": "CWE-377 Insecure Temporary File",
"lang": "en",
"type": "CWE"
}
]
}
],
"providerMetadata": {
"dateUpdated": "2026-10-30T09:01:05.212Z",
"orgId": "6b3ad84c-e1a6-4bf7-a703-f496b71e49db",
"shortName": "EEF"
},
"references": [
{
"name": "Insecure temporary file in mix deps.partition lets a local user execute code in another user\u0027s dependency build",
"tags": [
"vendor-advisory",
"related"
],
"url": "https://github.com/elixir-lang/elixir/security/advisories/GHSA-96q6-xhwp-fc8q"
},
{
"name": "EEF CNA record for CVE-2026-97209",
"tags": [
"related"
],
"url": "https://cna.erlef.org/cves/CVE-2026-97209.html"
},
{
"name": "OSV record EEF-CVE-2026-97209",
"tags": [
"related"
],
"url": "https://osv.dev/vulnerability/EEF-CVE-2026-97209"
},
{
"name": "Introducing commit 7f3d95b in elixir-lang/elixir",
"tags": [
"related"
],
"url": "https://github.com/elixir-lang/elixir/commit/7f3d95b60c28ae3026a5c1bf943166391245bfd1"
},
{
"name": "Fix commit 55eb912 in elixir-lang/elixir",
"tags": [
"patch"
],
"url": "https://github.com/elixir-lang/elixir/commit/55eb91220a1de14dc5aa27029d1f45b0f5832b2d"
},
{
"name": "Fix commit 9adbe95 in elixir-lang/elixir",
"tags": [
"patch"
],
"url": "https://github.com/elixir-lang/elixir/commit/9adbe955c8664f4be77d879dd38c003dd39fa777"
}
],
"source": {
"discovery": "EXTERNAL"
},
"title": "Insecure temporary file in mix deps.partition lets a local user execute code in another user\u0027s dependency build",
"workarounds": [
{
"lang": "en",
"supportingMedia": [
{
"base64": false,
"type": "text/html",
"value": "\u003cp\u003eOn a multi-user host, set \u003ccode\u003eTMPDIR\u003c/code\u003e to a private per-user directory with mode \u003ccode\u003e0700\u003c/code\u003e before running Mix, so that \u003ccode\u003eSystem.tmp_dir/0\u003c/code\u003e resolves to a location other users cannot pre-create or read. Alternatively, keep partitioned compilation disabled by leaving \u003ccode\u003eMIX_OS_DEPS_COMPILE_PARTITION_COUNT\u003c/code\u003e unset or at \u003ccode\u003e1\u003c/code\u003e, which keeps the configuration inside the parent process.\u003c/p\u003e"
},
{
"base64": false,
"type": "text/markdown",
"value": "On a multi-user host, set `TMPDIR` to a private per-user directory with mode `0700` before running Mix, so that `System.tmp_dir/0` resolves to a location other users cannot pre-create or read. Alternatively, keep partitioned compilation disabled by leaving `MIX_OS_DEPS_COMPILE_PARTITION_COUNT` unset or at `1`, which keeps the configuration inside the parent process."
}
],
"value": "On a multi-user host, set TMPDIR to a private per-user directory with mode 0700 before running Mix, so that System.tmp_dir/0 resolves to a location other users cannot pre-create or read. Alternatively, keep partitioned compilation disabled by leaving MIX_OS_DEPS_COMPILE_PARTITION_COUNT unset or at 1, which keeps the configuration inside the parent process."
}
],
"x_proofOfConcept": [
{
"lang": "en",
"supportingMedia": [
{
"base64": false,
"type": "text/html",
"value": "\u003col\u003e\n\u003cli\u003eOn a multi-user Linux host where \u003ccode\u003eSystem.tmp_dir()\u003c/code\u003e resolves to \u003ccode\u003e/tmp\u003c/code\u003e, the attacker creates \u003ccode\u003e/tmp/mix_deps_partition\u003c/code\u003e as their own directory and watches it for new files.\u003c/li\u003e\n\u003cli\u003eThe victim runs \u003ccode\u003emix deps.compile\u003c/code\u003e (directly, or through \u003ccode\u003emix compile\u003c/code\u003e or \u003ccode\u003emix test\u003c/code\u003e) with \u003ccode\u003eMIX_OS_DEPS_COMPILE_PARTITION_COUNT=2\u003c/code\u003e on a project with at least two non-local dependencies. \u003ccode\u003eFile.mkdir_p/1\u003c/code\u003e succeeds on the existing directory and the config file is written into it.\u003c/li\u003e\n\u003cli\u003eBefore the partition VMs boot and read the file, the attacker decodes it, adds a \u003ccode\u003ecompile:\u003c/code\u003e shell command to every dependency in \u003ccode\u003econfig[:deps]\u003c/code\u003e, re-encodes it and writes it back.\u003c/li\u003e\n\u003cli\u003eEach partition decodes the attacker\u0027s term and \u003ccode\u003edeps.compile\u003c/code\u003e runs the injected command as the victim user.\u003c/li\u003e\n\u003c/ol\u003e"
},
{
"base64": false,
"type": "text/markdown",
"value": "1. On a multi-user Linux host where `System.tmp_dir()` resolves to `/tmp`, the attacker creates `/tmp/mix_deps_partition` as their own directory and watches it for new files.\n2. The victim runs `mix deps.compile` (directly, or through `mix compile` or `mix test`) with `MIX_OS_DEPS_COMPILE_PARTITION_COUNT=2` on a project with at least two non-local dependencies. `File.mkdir_p/1` succeeds on the existing directory and the config file is written into it.\n3. Before the partition VMs boot and read the file, the attacker decodes it, adds a `compile:` shell command to every dependency in `config[:deps]`, re-encodes it and writes it back.\n4. Each partition decodes the attacker\u0027s term and `deps.compile` runs the injected command as the victim user."
}
],
"value": "* On a multi-user Linux host where System.tmp_dir() resolves to /tmp, the attacker creates /tmp/mix_deps_partition as their own directory and watches it for new files.\n* The victim runs mix deps.compile (directly, or through mix compile or mix test) with MIX_OS_DEPS_COMPILE_PARTITION_COUNT=2 on a project with at least two non-local dependencies. File.mkdir_p/1 succeeds on the existing directory and the config file is written into it.\n* Before the partition VMs boot and read the file, the attacker decodes it, adds a compile: shell command to every dependency in config[:deps], re-encodes it and writes it back.\n* Each partition decodes the attacker\u0027s term and deps.compile runs the injected command as the victim user."
}
],
"x_technicalAnalysis": [
{
"lang": "en",
"supportingMedia": [
{
"base64": false,
"type": "text/html",
"value": "\u003cp\u003e\u003cstrong\u003e1. Payload written to a shared location\u003c/strong\u003e\u003c/p\u003e\n\u003cp\u003e\u003ccode\u003eMix.Tasks.Deps.Partition.server/4\u003c/code\u003e in \u003ccode\u003elib/mix/lib/mix/tasks/deps.partition.ex\u003c/code\u003e encodes \u003ccode\u003eMix.ProjectStack.peek()\u003c/code\u003e with \u003ccode\u003e:erlang.term_to_binary/1\u003c/code\u003e and Base64, calls \u003ccode\u003eFile.mkdir_p/1\u003c/code\u003e on \u003ccode\u003ePath.join(System.tmp_dir(), \"mix_deps_partition\")\u003c/code\u003e, and writes the payload to a file with a random name inside it. The directory name is fixed, carries no user id, and \u003ccode\u003eFile.mkdir_p/1\u003c/code\u003e succeeds on a directory that already exists, whoever owns it. The file is created with the default mode, so it is readable by other users in a shared temporary directory.\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003e2. Partition VMs trust the file\u003c/strong\u003e\u003c/p\u003e\n\u003cp\u003eEach partition is a fresh \u003ccode\u003eelixir\u003c/code\u003e VM started with \u003ccode\u003e--config-file \u0026lt;path\u0026gt;\u003c/code\u003e. \u003ccode\u003eMix.Tasks.Deps.Partition.client/0\u003c/code\u003e reads the file with \u003ccode\u003eFile.read!/1\u003c/code\u003e, decodes it with \u003ccode\u003e:erlang.binary_to_term/1\u003c/code\u003e, and pushes the decoded entry (project name, config and \u003ccode\u003emix.exs\u003c/code\u003e path) with \u003ccode\u003eMix.ProjectStack.push/3\u003c/code\u003e. Nothing verifies that the file still holds what the parent wrote.\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003e3. Attacker control\u003c/strong\u003e\u003c/p\u003e\n\u003cp\u003eA local user who created \u003ccode\u003emix_deps_partition\u003c/code\u003e before the victim\u0027s build owns it and may replace or rewrite any file inside, so they swap the payload between the parent\u0027s write and the client\u0027s read. The client then compiles the attacker\u0027s \u003ccode\u003edeps\u003c/code\u003e list, and a dependency\u0027s \u003ccode\u003ecompile:\u003c/code\u003e option is a shell command Mix executes in the victim\u0027s build. On the development branch the payload also carries \u003ccode\u003ecode_path\u003c/code\u003e and \u003ccode\u003ecompiler_options\u003c/code\u003e, which the client applies with \u003ccode\u003e:code.set_path/1\u003c/code\u003e and \u003ccode\u003eCode.compiler_options/1\u003c/code\u003e, so there the attacker also controls the client VM\u0027s code path.\u003c/p\u003e"
},
{
"base64": false,
"type": "text/markdown",
"value": "**1. Payload written to a shared location**\n\n`Mix.Tasks.Deps.Partition.server/4` in `lib/mix/lib/mix/tasks/deps.partition.ex` encodes `Mix.ProjectStack.peek()` with `:erlang.term_to_binary/1` and Base64, calls `File.mkdir_p/1` on `Path.join(System.tmp_dir(), \"mix_deps_partition\")`, and writes the payload to a file with a random name inside it. The directory name is fixed, carries no user id, and `File.mkdir_p/1` succeeds on a directory that already exists, whoever owns it. The file is created with the default mode, so it is readable by other users in a shared temporary directory.\n\n**2. Partition VMs trust the file**\n\nEach partition is a fresh `elixir` VM started with `--config-file \u003cpath\u003e`. `Mix.Tasks.Deps.Partition.client/0` reads the file with `File.read!/1`, decodes it with `:erlang.binary_to_term/1`, and pushes the decoded entry (project name, config and `mix.exs` path) with `Mix.ProjectStack.push/3`. Nothing verifies that the file still holds what the parent wrote.\n\n**3. Attacker control**\n\nA local user who created `mix_deps_partition` before the victim\u0027s build owns it and may replace or rewrite any file inside, so they swap the payload between the parent\u0027s write and the client\u0027s read. The client then compiles the attacker\u0027s `deps` list, and a dependency\u0027s `compile:` option is a shell command Mix executes in the victim\u0027s build. On the development branch the payload also carries `code_path` and `compiler_options`, which the client applies with `:code.set_path/1` and `Code.compiler_options/1`, so there the attacker also controls the client VM\u0027s code path."
}
],
"value": "1. Payload written to a shared location\n\nMix.Tasks.Deps.Partition.server/4 in lib/mix/lib/mix/tasks/deps.partition.ex encodes Mix.ProjectStack.peek() with :erlang.term_to_binary/1 and Base64, calls File.mkdir_p/1 on Path.join(System.tmp_dir(), \"mix_deps_partition\"), and writes the payload to a file with a random name inside it. The directory name is fixed, carries no user id, and File.mkdir_p/1 succeeds on a directory that already exists, whoever owns it. The file is created with the default mode, so it is readable by other users in a shared temporary directory.\n\n2. Partition VMs trust the file\n\nEach partition is a fresh elixir VM started with --config-file \u003cpath\u003e. Mix.Tasks.Deps.Partition.client/0 reads the file with File.read!/1, decodes it with :erlang.binary_to_term/1, and pushes the decoded entry (project name, config and mix.exs path) with Mix.ProjectStack.push/3. Nothing verifies that the file still holds what the parent wrote.\n\n3. Attacker control\n\nA local user who created mix_deps_partition before the victim\u0027s build owns it and may replace or rewrite any file inside, so they swap the payload between the parent\u0027s write and the client\u0027s read. The client then compiles the attacker\u0027s deps list, and a dependency\u0027s compile: option is a shell command Mix executes in the victim\u0027s build. On the development branch the payload also carries code_path and compiler_options, which the client applies with :code.set_path/1 and Code.compiler_options/1, so there the attacker also controls the client VM\u0027s code path."
}
]
}
},
"cveMetadata": {
"assignerOrgId": "6b3ad84c-e1a6-4bf7-a703-f496b71e49db",
"assignerShortName": "EEF",
"cveId": "CVE-2026-97209",
"datePublished": "2026-10-30T08:59:17.615Z",
"dateReserved": "2026-10-14T21:45:01.541Z",
"dateUpdated": "2026-10-30T16:50:46.465Z",
"state": "PUBLISHED"
},
"dataType": "CVE_RECORD",
"dataVersion": "5.2"
}
CVE-2026-115274 (GCVE-0-2026-115274)
Vulnerability from cvelistv5 – Published: 2026-10-30 08:57 – Updated: 2026-10-30 16:51
VLAI
EPSS
VEX
Title
Unbounded integer parsing in JSON.decode/1 enables CPU and memory exhaustion denial of service in Elixir
Summary
Improper Validation of Specified Quantity in Input vulnerability in the Elixir standard library's JSON module allows an unauthenticated attacker who supplies a JSON document to cause a denial of service through CPU and memory exhaustion.
JSON.decode/1 and JSON.decode!/1 convert each integer literal with erlang:binary_to_integer/1 and do not bound its length. A literal of one million digits decodes successfully but costs 100 to 300 times as much CPU as a string of the same length, so one request body of a few megabytes holds a scheduler for about a second. A literal of more than about 1.29 million digits raises SystemLimitError. Applications generated by phx.new decode request bodies with JSON in Plug.Parsers, before routing and authentication.
This issue affects elixir: from 1.18.0-rc.0 before 1.18.6, from 1.19.0-rc.0 before 1.19.7, and from 1.20.0-rc.0 before 1.20.5.
Severity
SSVC
Exploitation: poc
Automatable: yes
Technical Impact: partial
CISA Coordinator · CISA-ADP (v2.0.3)
Decision recorded 2026-10-30 16:51 UTC
CWE
- CWE-1284 - Improper Validation of Specified Quantity in Input
Assigner
References
8 references
Impacted products
2 products
| Vendor | Product | Version | |
|---|---|---|---|
| elixir-lang | elixir |
Affected:
1.18.0-rc.0 , < 1.18.6
(semver)
Affected: 1.19.0-rc.0 , < 1.19.7 (semver) Affected: 1.20.0-rc.0 , < 1.20.5 (semver) cpe:2.3:a:elixir-lang:elixir:*:*:*:*:*:*:*:* |
|
| elixir-lang | elixir |
Affected:
793c1147210388587defb649f25f293ffbfa6b1b , < *
(git)
cpe:2.3:a:elixir-lang:elixir:*:*:*:*:*:*:*:* |
Date Public
2026-10-30 08:57
{
"containers": {
"adp": [
{
"metrics": [
{
"other": {
"content": {
"id": "CVE-2026-115274",
"options": [
{
"Exploitation": "poc"
},
{
"Automatable": "yes"
},
{
"Technical Impact": "partial"
}
],
"role": "CISA Coordinator",
"timestamp": "2026-10-30T16:51:17.679403Z",
"version": "2.0.3"
},
"type": "ssvc"
}
}
],
"providerMetadata": {
"dateUpdated": "2026-10-30T16:51:45.396Z",
"orgId": "134c704f-9b21-4f2e-91b3-4a467353bcc0",
"shortName": "CISA-ADP"
},
"references": [
{
"tags": [
"exploit"
],
"url": "https://github.com/elixir-lang/elixir/security/advisories/GHSA-rp5q-xqhq-926h"
}
],
"title": "CISA ADP Vulnrichment"
}
],
"cna": {
"affected": [
{
"cpes": [
"cpe:2.3:a:elixir-lang:elixir:*:*:*:*:*:*:*:*"
],
"defaultStatus": "unaffected",
"modules": [
"\u0027Elixir.JSON\u0027",
"elixir_json"
],
"packageName": "elixir",
"packageURL": "pkg:otp/elixir?repository_url=https:%2F%2Fgithub.com%2Felixir-lang%2Felixir\u0026vcs_url=git%2Bhttps:%2F%2Fgithub.com%2Felixir-lang%2Felixir.git",
"product": "elixir",
"programFiles": [
"lib/json.ex",
"src/elixir_json.erl"
],
"programRoutines": [
{
"name": "\u0027Elixir.JSON\u0027:decode/1"
},
{
"name": "\u0027Elixir.JSON\u0027:\u0027decode!\u0027/1"
},
{
"name": "\u0027Elixir.JSON\u0027:decode/3"
},
{
"name": "elixir_json:decode/3"
}
],
"repo": "https://github.com/elixir-lang/elixir",
"vendor": "elixir-lang",
"versions": [
{
"lessThan": "1.18.6",
"status": "affected",
"version": "1.18.0-rc.0",
"versionType": "semver"
},
{
"lessThan": "1.19.7",
"status": "affected",
"version": "1.19.0-rc.0",
"versionType": "semver"
},
{
"lessThan": "1.20.5",
"status": "affected",
"version": "1.20.0-rc.0",
"versionType": "semver"
}
]
},
{
"collectionURL": "https://github.com",
"cpes": [
"cpe:2.3:a:elixir-lang:elixir:*:*:*:*:*:*:*:*"
],
"defaultStatus": "unaffected",
"modules": [
"\u0027Elixir.JSON\u0027",
"elixir_json"
],
"packageName": "elixir-lang/elixir",
"packageURL": "pkg:github/elixir-lang/elixir",
"product": "elixir",
"programFiles": [
"lib/elixir/lib/json.ex",
"lib/elixir/src/elixir_json.erl"
],
"programRoutines": [
{
"name": "\u0027Elixir.JSON\u0027:decode/1"
},
{
"name": "\u0027Elixir.JSON\u0027:\u0027decode!\u0027/1"
},
{
"name": "\u0027Elixir.JSON\u0027:decode/3"
},
{
"name": "elixir_json:decode/3"
}
],
"repo": "https://github.com/elixir-lang/elixir",
"vendor": "elixir-lang",
"versions": [
{
"changes": [
{
"at": "c81f12265919c34a0d5134f1905a4eb4edaea1c2",
"status": "unaffected"
},
{
"at": "7154e8781da9fd016a973ea01776a011c8b5726a",
"status": "unaffected"
},
{
"at": "cd158efacdc10b30232864251747dff9b8387dfe",
"status": "unaffected"
},
{
"at": "ee5cc23f8928aa5a0f25bb7f28e42ecfc7b7d396",
"status": "unaffected"
}
],
"lessThan": "*",
"status": "affected",
"version": "793c1147210388587defb649f25f293ffbfa6b1b",
"versionType": "git"
}
]
}
],
"cpeApplicability": [
{
"nodes": [
{
"cpeMatch": [
{
"criteria": "cpe:2.3:a:elixir-lang:elixir:*:*:*:*:*:*:*:*",
"versionEndExcluding": "1.18.6",
"versionStartIncluding": "1.18.0-rc.0",
"vulnerable": true
},
{
"criteria": "cpe:2.3:a:elixir-lang:elixir:*:*:*:*:*:*:*:*",
"versionEndExcluding": "1.19.7",
"versionStartIncluding": "1.19.0-rc.0",
"vulnerable": true
},
{
"criteria": "cpe:2.3:a:elixir-lang:elixir:*:*:*:*:*:*:*:*",
"versionEndExcluding": "1.20.5",
"versionStartIncluding": "1.20.0-rc.0",
"vulnerable": true
}
],
"negate": false,
"operator": "OR"
}
],
"operator": "AND"
}
],
"credits": [
{
"lang": "en",
"type": "finder",
"value": "David Vieira Kurz"
},
{
"lang": "en",
"type": "finder",
"value": "Peter Ullrich"
},
{
"lang": "en",
"type": "reporter",
"value": "David Vieira Kurz"
},
{
"lang": "en",
"type": "remediation developer",
"value": "Jos\u00e9 Valim"
},
{
"lang": "en",
"type": "coordinator",
"value": "Jonatan M\u00e4nnchen / EEF"
}
],
"dateAssigned": "2026-10-25T12:21:17.000Z",
"datePublic": "2026-10-30T08:57:17.000Z",
"descriptions": [
{
"lang": "en",
"supportingMedia": [
{
"base64": false,
"type": "text/html",
"value": "\u003cp\u003eImproper Validation of Specified Quantity in Input vulnerability in the Elixir standard library\u0027s \u003ccode\u003eJSON\u003c/code\u003e module allows an unauthenticated attacker who supplies a JSON document to cause a denial of service through CPU and memory exhaustion.\u003c/p\u003e\n\u003cp\u003e\u003ccode\u003eJSON.decode/1\u003c/code\u003e and \u003ccode\u003eJSON.decode!/1\u003c/code\u003e convert each integer literal with \u003ccode\u003eerlang:binary_to_integer/1\u003c/code\u003e and do not bound its length. A literal of one million digits decodes successfully but costs 100 to 300 times as much CPU as a string of the same length, so one request body of a few megabytes holds a scheduler for about a second. A literal of more than about 1.29 million digits raises \u003ccode\u003eSystemLimitError\u003c/code\u003e. Applications generated by \u003ccode\u003ephx.new\u003c/code\u003e decode request bodies with \u003ccode\u003eJSON\u003c/code\u003e in \u003ccode\u003ePlug.Parsers\u003c/code\u003e, before routing and authentication.\u003c/p\u003e\n\u003cp\u003eThis issue affects elixir: from 1.18.0-rc.0 before 1.18.6, from 1.19.0-rc.0 before 1.19.7, and from 1.20.0-rc.0 before 1.20.5.\u003c/p\u003e"
},
{
"base64": false,
"type": "text/markdown",
"value": "Improper Validation of Specified Quantity in Input vulnerability in the Elixir standard library\u0027s `JSON` module allows an unauthenticated attacker who supplies a JSON document to cause a denial of service through CPU and memory exhaustion.\n\n`JSON.decode/1` and `JSON.decode!/1` convert each integer literal with `erlang:binary_to_integer/1` and do not bound its length. A literal of one million digits decodes successfully but costs 100 to 300 times as much CPU as a string of the same length, so one request body of a few megabytes holds a scheduler for about a second. A literal of more than about 1.29 million digits raises `SystemLimitError`. Applications generated by `phx.new` decode request bodies with `JSON` in `Plug.Parsers`, before routing and authentication.\n\nThis issue affects elixir: from 1.18.0-rc.0 before 1.18.6, from 1.19.0-rc.0 before 1.19.7, and from 1.20.0-rc.0 before 1.20.5."
}
],
"value": "Improper Validation of Specified Quantity in Input vulnerability in the Elixir standard library\u0027s JSON module allows an unauthenticated attacker who supplies a JSON document to cause a denial of service through CPU and memory exhaustion.\n\nJSON.decode/1 and JSON.decode!/1 convert each integer literal with erlang:binary_to_integer/1 and do not bound its length. A literal of one million digits decodes successfully but costs 100 to 300 times as much CPU as a string of the same length, so one request body of a few megabytes holds a scheduler for about a second. A literal of more than about 1.29 million digits raises SystemLimitError. Applications generated by phx.new decode request bodies with JSON in Plug.Parsers, before routing and authentication.\n\nThis issue affects elixir: from 1.18.0-rc.0 before 1.18.6, from 1.19.0-rc.0 before 1.19.7, and from 1.20.0-rc.0 before 1.20.5."
}
],
"impacts": [
{
"capecId": "CAPEC-130",
"descriptions": [
{
"lang": "en",
"supportingMedia": [
{
"base64": false,
"type": "text/html",
"value": "\u003cp\u003eAn unauthenticated client that sends a JSON body with integer literals of about a million digits makes the server spend about a second of CPU per request and allocate large bignums. Sustained requests exhaust CPU on applications that decode untrusted JSON with \u003ccode\u003eJSON\u003c/code\u003e, including new Phoenix applications by default.\u003c/p\u003e"
},
{
"base64": false,
"type": "text/markdown",
"value": "An unauthenticated client that sends a JSON body with integer literals of about a million digits makes the server spend about a second of CPU per request and allocate large bignums. Sustained requests exhaust CPU on applications that decode untrusted JSON with `JSON`, including new Phoenix applications by default."
}
],
"value": "An unauthenticated client that sends a JSON body with integer literals of about a million digits makes the server spend about a second of CPU per request and allocate large bignums. Sustained requests exhaust CPU on applications that decode untrusted JSON with JSON, including new Phoenix applications by default."
}
]
}
],
"metrics": [
{
"cvssV4_0": {
"Automatable": "NOT_DEFINED",
"Recovery": "NOT_DEFINED",
"Safety": "NOT_DEFINED",
"attackComplexity": "LOW",
"attackRequirements": "NONE",
"attackVector": "NETWORK",
"baseScore": 6.9,
"baseSeverity": "MEDIUM",
"privilegesRequired": "NONE",
"providerUrgency": "NOT_DEFINED",
"subAvailabilityImpact": "NONE",
"subConfidentialityImpact": "NONE",
"subIntegrityImpact": "NONE",
"userInteraction": "NONE",
"valueDensity": "NOT_DEFINED",
"vectorString": "CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:N/VI:N/VA:L/SC:N/SI:N/SA:N",
"version": "4.0",
"vulnAvailabilityImpact": "LOW",
"vulnConfidentialityImpact": "NONE",
"vulnIntegrityImpact": "NONE",
"vulnerabilityResponseEffort": "NOT_DEFINED"
},
"format": "CVSS",
"scenarios": [
{
"lang": "en",
"value": "GENERAL"
}
]
}
],
"problemTypes": [
{
"descriptions": [
{
"cweId": "CWE-1284",
"description": "CWE-1284 Improper Validation of Specified Quantity in Input",
"lang": "en",
"type": "CWE"
}
]
}
],
"providerMetadata": {
"dateUpdated": "2026-10-30T09:01:54.926Z",
"orgId": "6b3ad84c-e1a6-4bf7-a703-f496b71e49db",
"shortName": "EEF"
},
"references": [
{
"name": "Unbounded integer parsing in JSON.decode/1 enables CPU and memory exhaustion denial of service in Elixir",
"tags": [
"vendor-advisory",
"related"
],
"url": "https://github.com/elixir-lang/elixir/security/advisories/GHSA-rp5q-xqhq-926h"
},
{
"name": "EEF CNA record for CVE-2026-115274",
"tags": [
"related"
],
"url": "https://cna.erlef.org/cves/CVE-2026-115274.html"
},
{
"name": "OSV record EEF-CVE-2026-115274",
"tags": [
"related"
],
"url": "https://osv.dev/vulnerability/EEF-CVE-2026-115274"
},
{
"name": "Introducing commit 793c114 in elixir-lang/elixir",
"tags": [
"related"
],
"url": "https://github.com/elixir-lang/elixir/commit/793c1147210388587defb649f25f293ffbfa6b1b"
},
{
"name": "Fix commit c81f122 in elixir-lang/elixir",
"tags": [
"patch"
],
"url": "https://github.com/elixir-lang/elixir/commit/c81f12265919c34a0d5134f1905a4eb4edaea1c2"
},
{
"name": "Fix commit 7154e87 in elixir-lang/elixir",
"tags": [
"patch"
],
"url": "https://github.com/elixir-lang/elixir/commit/7154e8781da9fd016a973ea01776a011c8b5726a"
},
{
"name": "Fix commit cd158ef in elixir-lang/elixir",
"tags": [
"patch"
],
"url": "https://github.com/elixir-lang/elixir/commit/cd158efacdc10b30232864251747dff9b8387dfe"
},
{
"name": "Fix commit ee5cc23 in elixir-lang/elixir",
"tags": [
"patch"
],
"url": "https://github.com/elixir-lang/elixir/commit/ee5cc23f8928aa5a0f25bb7f28e42ecfc7b7d396"
}
],
"source": {
"discovery": "EXTERNAL"
},
"title": "Unbounded integer parsing in JSON.decode/1 enables CPU and memory exhaustion denial of service in Elixir",
"workarounds": [
{
"lang": "en",
"supportingMedia": [
{
"base64": false,
"type": "text/html",
"value": "\u003cp\u003eDecode untrusted JSON with an \u003ccode\u003einteger\u003c/code\u003e decoder that rejects long digit runs before conversion:\u003c/p\u003e\n\u003cpre\u003e\u003ccode class=\"language-elixir\"\u003edefmodule SafeJSON do\n def decode!(binary) do\n case JSON.decode(binary, :ok, integer: \u0026amp;safe_integer/1) do\n {decoded, :ok, \"\"} -\u0026gt; decoded\n _ -\u0026gt; raise ArgumentError, \"invalid JSON\"\n end\n end\n\n defp safe_integer(digits) when byte_size(digits) \u0026lt;= 1024, do: String.to_integer(digits)\n defp safe_integer(_digits), do: raise(ArgumentError, \"integer too long\")\nend\n\u003c/code\u003e\u003c/pre\u003e\n\u003cp\u003eIn Phoenix, pass \u003ccode\u003eSafeJSON\u003c/code\u003e to \u003ccode\u003ePlug.Parsers\u003c/code\u003e as \u003ccode\u003ejson_decoder\u003c/code\u003e instead of \u003ccode\u003ePhoenix.json_library()\u003c/code\u003e.\u003c/p\u003e"
},
{
"base64": false,
"type": "text/markdown",
"value": "Decode untrusted JSON with an `integer` decoder that rejects long digit runs before conversion:\n\n```elixir\ndefmodule SafeJSON do\n def decode!(binary) do\n case JSON.decode(binary, :ok, integer: \u0026safe_integer/1) do\n {decoded, :ok, \"\"} -\u003e decoded\n _ -\u003e raise ArgumentError, \"invalid JSON\"\n end\n end\n\n defp safe_integer(digits) when byte_size(digits) \u003c= 1024, do: String.to_integer(digits)\n defp safe_integer(_digits), do: raise(ArgumentError, \"integer too long\")\nend\n```\n\nIn Phoenix, pass `SafeJSON` to `Plug.Parsers` as `json_decoder` instead of `Phoenix.json_library()`."
}
],
"value": "Decode untrusted JSON with an integer decoder that rejects long digit runs before conversion:\n\ndefmodule SafeJSON do\n def decode!(binary) do\n case JSON.decode(binary, :ok, integer: \u0026safe_integer/1) do\n {decoded, :ok, \"\"} -\u003e decoded\n _ -\u003e raise ArgumentError, \"invalid JSON\"\n end\n end\n\n defp safe_integer(digits) when byte_size(digits) \u003c= 1024, do: String.to_integer(digits)\n defp safe_integer(_digits), do: raise(ArgumentError, \"integer too long\")\nend\n\nIn Phoenix, pass SafeJSON to Plug.Parsers as json_decoder instead of Phoenix.json_library()."
}
],
"x_proofOfConcept": [
{
"lang": "en",
"supportingMedia": [
{
"base64": false,
"type": "text/html",
"value": "\u003col\u003e\n\u003cli\u003eBuild \u003ccode\u003ebody = \"[\" \u0026lt;\u0026gt; Enum.map_join(1..7, \",\", fn _ -\u0026gt; String.duplicate(\"9\", 1_000_000) end) \u0026lt;\u0026gt; \"]\"\u003c/code\u003e, a body of about 7 MB.\u003c/li\u003e\n\u003cli\u003e\u003ccode\u003eJSON.decode(body)\u003c/code\u003e returns \u003ccode\u003e{:ok, ...}\u003c/code\u003e after about one second of CPU.\u003c/li\u003e\n\u003cli\u003e\u003ccode\u003eJSON.decode(String.duplicate(\"9\", 1_000_000))\u003c/code\u003e takes 100 to 300 times as long as \u003ccode\u003eJSON.decode(\"\\\"\" \u0026lt;\u0026gt; String.duplicate(\"9\", 1_000_000) \u0026lt;\u0026gt; \"\\\"\")\u003c/code\u003e.\u003c/li\u003e\n\u003cli\u003e\u003ccode\u003eJSON.decode(String.duplicate(\"9\", 1_400_000))\u003c/code\u003e raises \u003ccode\u003eSystemLimitError\u003c/code\u003e.\u003c/li\u003e\n\u003c/ol\u003e"
},
{
"base64": false,
"type": "text/markdown",
"value": "1. Build `body = \"[\" \u003c\u003e Enum.map_join(1..7, \",\", fn _ -\u003e String.duplicate(\"9\", 1_000_000) end) \u003c\u003e \"]\"`, a body of about 7 MB.\n2. `JSON.decode(body)` returns `{:ok, ...}` after about one second of CPU.\n3. `JSON.decode(String.duplicate(\"9\", 1_000_000))` takes 100 to 300 times as long as `JSON.decode(\"\\\"\" \u003c\u003e String.duplicate(\"9\", 1_000_000) \u003c\u003e \"\\\"\")`.\n4. `JSON.decode(String.duplicate(\"9\", 1_400_000))` raises `SystemLimitError`."
}
],
"value": "* Build body = \"[\" \u003c\u003e Enum.map_join(1..7, \",\", fn _ -\u003e String.duplicate(\"9\", 1_000_000) end) \u003c\u003e \"]\", a body of about 7 MB.\n* JSON.decode(body) returns {:ok, ...} after about one second of CPU.\n* JSON.decode(String.duplicate(\"9\", 1_000_000)) takes 100 to 300 times as long as JSON.decode(\"\\\"\" \u003c\u003e String.duplicate(\"9\", 1_000_000) \u003c\u003e \"\\\"\").\n* JSON.decode(String.duplicate(\"9\", 1_400_000)) raises SystemLimitError."
}
],
"x_technicalAnalysis": [
{
"lang": "en",
"supportingMedia": [
{
"base64": false,
"type": "text/html",
"value": "\u003cp\u003e\u003cstrong\u003e1. Default integer decoder\u003c/strong\u003e\u003c/p\u003e\n\u003cp\u003e\u003ccode\u003eJSON.decode/1\u003c/code\u003e and \u003ccode\u003eJSON.decode!/1\u003c/code\u003e call \u003ccode\u003eJSON.decode/3\u003c/code\u003e without an \u003ccode\u003einteger\u003c/code\u003e decoder. Up to the removal of Erlang/OTP 26 support, \u003ccode\u003eJSON.decode/3\u003c/code\u003e used the vendored \u003ccode\u003eelixir_json\u003c/code\u003e module, whose default is \u003ccode\u003efun erlang:binary_to_integer/1\u003c/code\u003e. It now calls OTP\u0027s \u003ccode\u003e:json.decode/3\u003c/code\u003e, which has the same default. The \u003ccode\u003eJSON.decode/3\u003c/code\u003e documentation names \u003ccode\u003e\u0026amp;String.to_integer/1\u003c/code\u003e as the default, which converts the same way.\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003e2. Unbounded conversion\u003c/strong\u003e\u003c/p\u003e\n\u003cp\u003eThe decoder scans the digits of an integer literal and passes the whole run to the conversion. Nothing limits the run\u0027s length, so the cost grows faster than linearly with the number of digits. At one million digits an integer literal costs 100 to 300 times as much CPU as a string literal of the same length. Above about 1.29 million digits the conversion raises \u003ccode\u003eSystemLimitError\u003c/code\u003e, which \u003ccode\u003eJSON.decode/3\u003c/code\u003e does not catch.\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003e3. Reach\u003c/strong\u003e\u003c/p\u003e\n\u003cp\u003e\u003ccode\u003ephx.new\u003c/code\u003e sets \u003ccode\u003econfig :phoenix, :json_library, JSON\u003c/code\u003e, and the generated endpoint passes \u003ccode\u003ePhoenix.json_library()\u003c/code\u003e to \u003ccode\u003ePlug.Parsers\u003c/code\u003e as \u003ccode\u003ejson_decoder\u003c/code\u003e. \u003ccode\u003ePlug.Parsers\u003c/code\u003e runs before routing and authentication and accepts bodies up to 8 MB by default. It rescues the \u003ccode\u003eSystemLimitError\u003c/code\u003e into a 400 response after the CPU cost is spent. Jason, the previous default, rejects integer literals longer than its \u003ccode\u003edecoding_integer_digit_limit\u003c/code\u003e (1024 by default) before conversion.\u003c/p\u003e"
},
{
"base64": false,
"type": "text/markdown",
"value": "**1. Default integer decoder**\n\n`JSON.decode/1` and `JSON.decode!/1` call `JSON.decode/3` without an `integer` decoder. Up to the removal of Erlang/OTP 26 support, `JSON.decode/3` used the vendored `elixir_json` module, whose default is `fun erlang:binary_to_integer/1`. It now calls OTP\u0027s `:json.decode/3`, which has the same default. The `JSON.decode/3` documentation names `\u0026String.to_integer/1` as the default, which converts the same way.\n\n**2. Unbounded conversion**\n\nThe decoder scans the digits of an integer literal and passes the whole run to the conversion. Nothing limits the run\u0027s length, so the cost grows faster than linearly with the number of digits. At one million digits an integer literal costs 100 to 300 times as much CPU as a string literal of the same length. Above about 1.29 million digits the conversion raises `SystemLimitError`, which `JSON.decode/3` does not catch.\n\n**3. Reach**\n\n`phx.new` sets `config :phoenix, :json_library, JSON`, and the generated endpoint passes `Phoenix.json_library()` to `Plug.Parsers` as `json_decoder`. `Plug.Parsers` runs before routing and authentication and accepts bodies up to 8 MB by default. It rescues the `SystemLimitError` into a 400 response after the CPU cost is spent. Jason, the previous default, rejects integer literals longer than its `decoding_integer_digit_limit` (1024 by default) before conversion."
}
],
"value": "1. Default integer decoder\n\nJSON.decode/1 and JSON.decode!/1 call JSON.decode/3 without an integer decoder. Up to the removal of Erlang/OTP 26 support, JSON.decode/3 used the vendored elixir_json module, whose default is fun erlang:binary_to_integer/1. It now calls OTP\u0027s :json.decode/3, which has the same default. The JSON.decode/3 documentation names \u0026String.to_integer/1 as the default, which converts the same way.\n\n2. Unbounded conversion\n\nThe decoder scans the digits of an integer literal and passes the whole run to the conversion. Nothing limits the run\u0027s length, so the cost grows faster than linearly with the number of digits. At one million digits an integer literal costs 100 to 300 times as much CPU as a string literal of the same length. Above about 1.29 million digits the conversion raises SystemLimitError, which JSON.decode/3 does not catch.\n\n3. Reach\n\nphx.new sets config :phoenix, :json_library, JSON, and the generated endpoint passes Phoenix.json_library() to Plug.Parsers as json_decoder. Plug.Parsers runs before routing and authentication and accepts bodies up to 8 MB by default. It rescues the SystemLimitError into a 400 response after the CPU cost is spent. Jason, the previous default, rejects integer literals longer than its decoding_integer_digit_limit (1024 by default) before conversion."
}
]
}
},
"cveMetadata": {
"assignerOrgId": "6b3ad84c-e1a6-4bf7-a703-f496b71e49db",
"assignerShortName": "EEF",
"cveId": "CVE-2026-115274",
"datePublished": "2026-10-30T08:57:16.847Z",
"dateReserved": "2026-10-23T12:45:01.103Z",
"dateUpdated": "2026-10-30T16:51:45.396Z",
"state": "PUBLISHED"
},
"dataType": "CVE_RECORD",
"dataVersion": "5.2"
}
CVE-2026-109821 (GCVE-0-2026-109821)
Vulnerability from cvelistv5 – Published: 2026-10-30 08:54 – Updated: 2026-10-30 16:52
VLAI
EPSS
VEX
Title
Unbounded port parsing in URI.parse/1 enables CPU exhaustion denial of service in Elixir
Summary
Improper Validation of Specified Quantity in Input vulnerability in the Elixir standard library's URI module allows an attacker who supplies a URL to cause a denial of service through CPU exhaustion.
URI.parse/1 converts the port component with String.to_integer/1 in split_authority/1 (lib/elixir/lib/uri.ex) without bounding its length, so a port of up to 1,262,592 decimal digits produces a bignum of that size and a longer one raises SystemLimitError. Rendering the parsed URI back to text (URI.to_string/1, inspect/1, logging, error formatting) converts the bignum with :erlang.integer_to_binary/1, which is quadratic on Erlang/OTP 27, 28 and 29.0: a 400,000-digit port renders in about 2.6 seconds and a 1 MB port in about 18 seconds. Ports above 65535 are also accepted.
This issue affects elixir: from 0.5.0 before 1.18.6, from 1.19.0-rc.0 before 1.19.7, and from 1.20.0-rc.0 before 1.20.5.
Severity
SSVC
Exploitation: poc
Automatable: no
Technical Impact: partial
CISA Coordinator · CISA-ADP (v2.0.3)
Decision recorded 2026-10-30 16:52 UTC
CWE
Assigner
References
9 references
Impacted products
2 products
| Vendor | Product | Version | |
|---|---|---|---|
| elixir-lang | elixir |
Affected:
0.5.0 , < 1.18.6
(semver)
Affected: 1.19.0-rc.0 , < 1.19.7 (semver) Affected: 1.20.0-rc.0 , < 1.20.5 (semver) cpe:2.3:a:elixir-lang:elixir:*:*:*:*:*:*:*:* |
|
| elixir-lang | elixir |
Affected:
39801821eef2d06524e5ebf5a0e4b3c380ad23ae , < *
(git)
cpe:2.3:a:elixir-lang:elixir:*:*:*:*:*:*:*:* |
Date Public
2026-10-30 08:54
{
"containers": {
"adp": [
{
"metrics": [
{
"other": {
"content": {
"id": "CVE-2026-109821",
"options": [
{
"Exploitation": "poc"
},
{
"Automatable": "no"
},
{
"Technical Impact": "partial"
}
],
"role": "CISA Coordinator",
"timestamp": "2026-10-30T16:52:15.736711Z",
"version": "2.0.3"
},
"type": "ssvc"
}
}
],
"providerMetadata": {
"dateUpdated": "2026-10-30T16:52:43.052Z",
"orgId": "134c704f-9b21-4f2e-91b3-4a467353bcc0",
"shortName": "CISA-ADP"
},
"references": [
{
"tags": [
"exploit"
],
"url": "https://github.com/elixir-lang/elixir/security/advisories/GHSA-327h-97cv-59xg"
}
],
"title": "CISA ADP Vulnrichment"
}
],
"cna": {
"affected": [
{
"cpes": [
"cpe:2.3:a:elixir-lang:elixir:*:*:*:*:*:*:*:*"
],
"defaultStatus": "unaffected",
"modules": [
"\u0027Elixir.URI\u0027",
"\u0027Elixir.String.Chars.URI\u0027"
],
"packageName": "elixir",
"packageURL": "pkg:otp/elixir?repository_url=https:%2F%2Fgithub.com%2Felixir-lang%2Felixir\u0026vcs_url=git%2Bhttps:%2F%2Fgithub.com%2Felixir-lang%2Felixir.git",
"product": "elixir",
"programFiles": [
"lib/uri.ex"
],
"programRoutines": [
{
"name": "\u0027Elixir.URI\u0027:parse/1"
},
{
"name": "\u0027Elixir.URI\u0027:split_authority/1"
},
{
"name": "\u0027Elixir.URI\u0027:merge/2"
},
{
"name": "\u0027Elixir.URI\u0027:to_string/1"
},
{
"name": "\u0027Elixir.String.Chars.URI\u0027:to_string/1"
}
],
"repo": "https://github.com/elixir-lang/elixir",
"vendor": "elixir-lang",
"versions": [
{
"lessThan": "1.18.6",
"status": "affected",
"version": "0.5.0",
"versionType": "semver"
},
{
"lessThan": "1.19.7",
"status": "affected",
"version": "1.19.0-rc.0",
"versionType": "semver"
},
{
"lessThan": "1.20.5",
"status": "affected",
"version": "1.20.0-rc.0",
"versionType": "semver"
}
]
},
{
"collectionURL": "https://github.com",
"cpes": [
"cpe:2.3:a:elixir-lang:elixir:*:*:*:*:*:*:*:*"
],
"defaultStatus": "unaffected",
"modules": [
"\u0027Elixir.URI\u0027",
"\u0027Elixir.String.Chars.URI\u0027"
],
"packageName": "elixir-lang/elixir",
"packageURL": "pkg:github/elixir-lang/elixir",
"product": "elixir",
"programFiles": [
"lib/elixir/lib/uri.ex"
],
"programRoutines": [
{
"name": "\u0027Elixir.URI\u0027:parse/1"
},
{
"name": "\u0027Elixir.URI\u0027:split_authority/1"
},
{
"name": "\u0027Elixir.URI\u0027:merge/2"
},
{
"name": "\u0027Elixir.URI\u0027:to_string/1"
},
{
"name": "\u0027Elixir.String.Chars.URI\u0027:to_string/1"
}
],
"repo": "https://github.com/elixir-lang/elixir",
"vendor": "elixir-lang",
"versions": [
{
"changes": [
{
"at": "9a39d64a4473d02681c486bc70681c972479d32b",
"status": "unaffected"
},
{
"at": "b70f33a591b6ab8eb0fa4a7e00bac1c2ed4e0d58",
"status": "unaffected"
},
{
"at": "07d9abc3f3ac4c8d2ba8384bd102b6d6205b08b7",
"status": "unaffected"
},
{
"at": "d879b39d778fec2f475689573d5672720c4f2a21",
"status": "unaffected"
}
],
"lessThan": "*",
"status": "affected",
"version": "39801821eef2d06524e5ebf5a0e4b3c380ad23ae",
"versionType": "git"
}
]
}
],
"configurations": [
{
"lang": "en",
"supportingMedia": [
{
"base64": false,
"type": "text/html",
"value": "\u003cp\u003eThe application parses an attacker-supplied URL with \u003ccode\u003eURI.parse/1\u003c/code\u003e, for example a URL field in a form or JSON request body, and then renders the result or a value containing it. The multi-second render cost requires Erlang/OTP 27, 28 or 29.0; on OTP 29.1 and later rendering a 1 MB port takes under a second. URLs in HTTP headers and the request line are bounded by the server\u0027s size limits.\u003c/p\u003e"
},
{
"base64": false,
"type": "text/markdown",
"value": "The application parses an attacker-supplied URL with `URI.parse/1`, for example a URL field in a form or JSON request body, and then renders the result or a value containing it. The multi-second render cost requires Erlang/OTP 27, 28 or 29.0; on OTP 29.1 and later rendering a 1 MB port takes under a second. URLs in HTTP headers and the request line are bounded by the server\u0027s size limits."
}
],
"value": "The application parses an attacker-supplied URL with URI.parse/1, for example a URL field in a form or JSON request body, and then renders the result or a value containing it. The multi-second render cost requires Erlang/OTP 27, 28 or 29.0; on OTP 29.1 and later rendering a 1 MB port takes under a second. URLs in HTTP headers and the request line are bounded by the server\u0027s size limits."
}
],
"cpeApplicability": [
{
"nodes": [
{
"cpeMatch": [
{
"criteria": "cpe:2.3:a:elixir-lang:elixir:*:*:*:*:*:*:*:*",
"versionEndExcluding": "1.18.6",
"versionStartIncluding": "0.5.0",
"vulnerable": true
},
{
"criteria": "cpe:2.3:a:elixir-lang:elixir:*:*:*:*:*:*:*:*",
"versionEndExcluding": "1.19.7",
"versionStartIncluding": "1.19.0-rc.0",
"vulnerable": true
},
{
"criteria": "cpe:2.3:a:elixir-lang:elixir:*:*:*:*:*:*:*:*",
"versionEndExcluding": "1.20.5",
"versionStartIncluding": "1.20.0-rc.0",
"vulnerable": true
}
],
"negate": false,
"operator": "OR"
}
],
"operator": "AND"
}
],
"credits": [
{
"lang": "en",
"type": "finder",
"value": "Eric Meadows-J\u00f6nsson"
},
{
"lang": "en",
"type": "reporter",
"value": "Eric Meadows-J\u00f6nsson"
},
{
"lang": "en",
"type": "remediation developer",
"value": "Jos\u00e9 Valim"
},
{
"lang": "en",
"type": "coordinator",
"value": "Eric Meadows-J\u00f6nsson"
},
{
"lang": "en",
"type": "analyst",
"value": "Jonatan M\u00e4nnchen / EEF"
}
],
"dateAssigned": "2026-10-25T10:39:55.000Z",
"datePublic": "2026-10-30T08:54:08.000Z",
"descriptions": [
{
"lang": "en",
"supportingMedia": [
{
"base64": false,
"type": "text/html",
"value": "\u003cp\u003eImproper Validation of Specified Quantity in Input vulnerability in the Elixir standard library\u0027s \u003ccode\u003eURI\u003c/code\u003e module allows an attacker who supplies a URL to cause a denial of service through CPU exhaustion.\u003c/p\u003e\n\u003cp\u003e\u003ccode\u003eURI.parse/1\u003c/code\u003e converts the port component with \u003ccode\u003eString.to_integer/1\u003c/code\u003e in \u003ccode\u003esplit_authority/1\u003c/code\u003e (\u003ccode\u003elib/elixir/lib/uri.ex\u003c/code\u003e) without bounding its length, so a port of up to 1,262,592 decimal digits produces a bignum of that size and a longer one raises \u003ccode\u003eSystemLimitError\u003c/code\u003e. Rendering the parsed URI back to text (\u003ccode\u003eURI.to_string/1\u003c/code\u003e, \u003ccode\u003einspect/1\u003c/code\u003e, logging, error formatting) converts the bignum with \u003ccode\u003e:erlang.integer_to_binary/1\u003c/code\u003e, which is quadratic on Erlang/OTP 27, 28 and 29.0: a 400,000-digit port renders in about 2.6 seconds and a 1 MB port in about 18 seconds. Ports above 65535 are also accepted.\u003c/p\u003e\n\u003cp\u003eThis issue affects elixir: from 0.5.0 before 1.18.6, from 1.19.0-rc.0 before 1.19.7, and from 1.20.0-rc.0 before 1.20.5.\u003c/p\u003e"
},
{
"base64": false,
"type": "text/markdown",
"value": "Improper Validation of Specified Quantity in Input vulnerability in the Elixir standard library\u0027s `URI` module allows an attacker who supplies a URL to cause a denial of service through CPU exhaustion.\n\n`URI.parse/1` converts the port component with `String.to_integer/1` in `split_authority/1` (`lib/elixir/lib/uri.ex`) without bounding its length, so a port of up to 1,262,592 decimal digits produces a bignum of that size and a longer one raises `SystemLimitError`. Rendering the parsed URI back to text (`URI.to_string/1`, `inspect/1`, logging, error formatting) converts the bignum with `:erlang.integer_to_binary/1`, which is quadratic on Erlang/OTP 27, 28 and 29.0: a 400,000-digit port renders in about 2.6 seconds and a 1 MB port in about 18 seconds. Ports above 65535 are also accepted.\n\nThis issue affects elixir: from 0.5.0 before 1.18.6, from 1.19.0-rc.0 before 1.19.7, and from 1.20.0-rc.0 before 1.20.5."
}
],
"value": "Improper Validation of Specified Quantity in Input vulnerability in the Elixir standard library\u0027s URI module allows an attacker who supplies a URL to cause a denial of service through CPU exhaustion.\n\nURI.parse/1 converts the port component with String.to_integer/1 in split_authority/1 (lib/elixir/lib/uri.ex) without bounding its length, so a port of up to 1,262,592 decimal digits produces a bignum of that size and a longer one raises SystemLimitError. Rendering the parsed URI back to text (URI.to_string/1, inspect/1, logging, error formatting) converts the bignum with :erlang.integer_to_binary/1, which is quadratic on Erlang/OTP 27, 28 and 29.0: a 400,000-digit port renders in about 2.6 seconds and a 1 MB port in about 18 seconds. Ports above 65535 are also accepted.\n\nThis issue affects elixir: from 0.5.0 before 1.18.6, from 1.19.0-rc.0 before 1.19.7, and from 1.20.0-rc.0 before 1.20.5."
}
],
"impacts": [
{
"capecId": "CAPEC-130",
"descriptions": [
{
"lang": "en",
"supportingMedia": [
{
"base64": false,
"type": "text/html",
"value": "\u003cp\u003eAn unauthenticated client that submits a URL with a port of hundreds of thousands of digits makes the server build a bignum of that size, and each later rendering of the parsed URI costs seconds of CPU on Erlang/OTP 27, 28 and 29.0. Sustained requests exhaust CPU on applications that parse and log, store or echo user-supplied URLs.\u003c/p\u003e"
},
{
"base64": false,
"type": "text/markdown",
"value": "An unauthenticated client that submits a URL with a port of hundreds of thousands of digits makes the server build a bignum of that size, and each later rendering of the parsed URI costs seconds of CPU on Erlang/OTP 27, 28 and 29.0. Sustained requests exhaust CPU on applications that parse and log, store or echo user-supplied URLs."
}
],
"value": "An unauthenticated client that submits a URL with a port of hundreds of thousands of digits makes the server build a bignum of that size, and each later rendering of the parsed URI costs seconds of CPU on Erlang/OTP 27, 28 and 29.0. Sustained requests exhaust CPU on applications that parse and log, store or echo user-supplied URLs."
}
]
}
],
"metrics": [
{
"cvssV4_0": {
"Automatable": "NOT_DEFINED",
"Recovery": "NOT_DEFINED",
"Safety": "NOT_DEFINED",
"attackComplexity": "LOW",
"attackRequirements": "PRESENT",
"attackVector": "NETWORK",
"baseScore": 6.3,
"baseSeverity": "MEDIUM",
"privilegesRequired": "NONE",
"providerUrgency": "NOT_DEFINED",
"subAvailabilityImpact": "NONE",
"subConfidentialityImpact": "NONE",
"subIntegrityImpact": "NONE",
"userInteraction": "NONE",
"valueDensity": "NOT_DEFINED",
"vectorString": "CVSS:4.0/AV:N/AC:L/AT:P/PR:N/UI:N/VC:N/VI:N/VA:L/SC:N/SI:N/SA:N",
"version": "4.0",
"vulnAvailabilityImpact": "LOW",
"vulnConfidentialityImpact": "NONE",
"vulnIntegrityImpact": "NONE",
"vulnerabilityResponseEffort": "NOT_DEFINED"
},
"format": "CVSS",
"scenarios": [
{
"lang": "en",
"value": "GENERAL"
}
]
}
],
"problemTypes": [
{
"descriptions": [
{
"cweId": "CWE-1284",
"description": "CWE-1284 Improper Validation of Specified Quantity in Input",
"lang": "en",
"type": "CWE"
}
]
},
{
"descriptions": [
{
"cweId": "CWE-400",
"description": "CWE-400 Uncontrolled Resource Consumption",
"lang": "en",
"type": "CWE"
}
]
}
],
"providerMetadata": {
"dateUpdated": "2026-10-30T09:02:51.828Z",
"orgId": "6b3ad84c-e1a6-4bf7-a703-f496b71e49db",
"shortName": "EEF"
},
"references": [
{
"name": "Unbounded port parsing in URI.parse/1 enables CPU exhaustion denial of service",
"tags": [
"vendor-advisory",
"related"
],
"url": "https://github.com/elixir-lang/elixir/security/advisories/GHSA-327h-97cv-59xg"
},
{
"name": "EEF CNA record for CVE-2026-109821",
"tags": [
"related"
],
"url": "https://cna.erlef.org/cves/CVE-2026-109821.html"
},
{
"name": "OSV record EEF-CVE-2026-109821",
"tags": [
"related"
],
"url": "https://osv.dev/vulnerability/EEF-CVE-2026-109821"
},
{
"name": "Erlang/OTP advisory for the same issue in uri_string",
"tags": [
"related"
],
"url": "https://github.com/erlang/otp/security/advisories/GHSA-8qw4-2chm-mvj2"
},
{
"name": "Introducing commit 3980182 in elixir-lang/elixir",
"tags": [
"related"
],
"url": "https://github.com/elixir-lang/elixir/commit/39801821eef2d06524e5ebf5a0e4b3c380ad23ae"
},
{
"name": "Fix commit 9a39d64 in elixir-lang/elixir",
"tags": [
"patch"
],
"url": "https://github.com/elixir-lang/elixir/commit/9a39d64a4473d02681c486bc70681c972479d32b"
},
{
"name": "Fix commit b70f33a in elixir-lang/elixir",
"tags": [
"patch"
],
"url": "https://github.com/elixir-lang/elixir/commit/b70f33a591b6ab8eb0fa4a7e00bac1c2ed4e0d58"
},
{
"name": "Fix commit 07d9abc in elixir-lang/elixir",
"tags": [
"patch"
],
"url": "https://github.com/elixir-lang/elixir/commit/07d9abc3f3ac4c8d2ba8384bd102b6d6205b08b7"
},
{
"name": "Fix commit d879b39 in elixir-lang/elixir",
"tags": [
"patch"
],
"url": "https://github.com/elixir-lang/elixir/commit/d879b39d778fec2f475689573d5672720c4f2a21"
}
],
"source": {
"discovery": "INTERNAL"
},
"title": "Unbounded port parsing in URI.parse/1 enables CPU exhaustion denial of service in Elixir",
"workarounds": [
{
"lang": "en",
"supportingMedia": [
{
"base64": false,
"type": "text/html",
"value": "\u003cp\u003eReject long URLs before parsing them, for example anything above a few kilobytes, and reject a parsed \u003ccode\u003eport\u003c/code\u003e above 65535 before rendering or using the \u003ccode\u003e%URI{}\u003c/code\u003e. \u003ccode\u003eURI.new/1\u003c/code\u003e is not affected on Erlang/OTP 27.3.4.17, 28.5.0.6, 29.0.6 and later, where \u003ccode\u003e:uri_string\u003c/code\u003e limits the port to 5 digits.\u003c/p\u003e"
},
{
"base64": false,
"type": "text/markdown",
"value": "Reject long URLs before parsing them, for example anything above a few kilobytes, and reject a parsed `port` above 65535 before rendering or using the `%URI{}`. `URI.new/1` is not affected on Erlang/OTP 27.3.4.17, 28.5.0.6, 29.0.6 and later, where `:uri_string` limits the port to 5 digits."
}
],
"value": "Reject long URLs before parsing them, for example anything above a few kilobytes, and reject a parsed port above 65535 before rendering or using the %URI{}. URI.new/1 is not affected on Erlang/OTP 27.3.4.17, 28.5.0.6, 29.0.6 and later, where :uri_string limits the port to 5 digits."
}
],
"x_proofOfConcept": [
{
"lang": "en",
"supportingMedia": [
{
"base64": false,
"type": "text/html",
"value": "\u003col\u003e\n\u003cli\u003eBuild \u003ccode\u003eurl = \"http://example.com:\" \u0026lt;\u0026gt; String.duplicate(\"9\", 400_000)\u003c/code\u003e.\u003c/li\u003e\n\u003cli\u003e\u003ccode\u003eURI.parse(url)\u003c/code\u003e returns in about 30 ms.\u003c/li\u003e\n\u003cli\u003e\u003ccode\u003eURI.to_string(URI.parse(url))\u003c/code\u003e takes 2,613 ms on OTP 27.3.4.18, 2,604 ms on OTP 28.5 and 108 ms on OTP 29.1.1. With 200,000 digits it takes 654 ms on OTP 27 and 28, so the cost grows 4x per doubling.\u003c/li\u003e\n\u003cli\u003eThe same digits placed in the path instead of the port parse and render in 1 ms or less.\u003c/li\u003e\n\u003cli\u003e\u003ccode\u003eURI.parse(\"http://example.com:65536\").port\u003c/code\u003e returns \u003ccode\u003e65536\u003c/code\u003e, and a port of 1,262,593 digits raises \u003ccode\u003eSystemLimitError\u003c/code\u003e.\u003c/li\u003e\n\u003c/ol\u003e"
},
{
"base64": false,
"type": "text/markdown",
"value": "1. Build `url = \"http://example.com:\" \u003c\u003e String.duplicate(\"9\", 400_000)`.\n2. `URI.parse(url)` returns in about 30 ms.\n3. `URI.to_string(URI.parse(url))` takes 2,613 ms on OTP 27.3.4.18, 2,604 ms on OTP 28.5 and 108 ms on OTP 29.1.1. With 200,000 digits it takes 654 ms on OTP 27 and 28, so the cost grows 4x per doubling.\n4. The same digits placed in the path instead of the port parse and render in 1 ms or less.\n5. `URI.parse(\"http://example.com:65536\").port` returns `65536`, and a port of 1,262,593 digits raises `SystemLimitError`."
}
],
"value": "* Build url = \"http://example.com:\" \u003c\u003e String.duplicate(\"9\", 400_000).\n* URI.parse(url) returns in about 30 ms.\n* URI.to_string(URI.parse(url)) takes 2,613 ms on OTP 27.3.4.18, 2,604 ms on OTP 28.5 and 108 ms on OTP 29.1.1. With 200,000 digits it takes 654 ms on OTP 27 and 28, so the cost grows 4x per doubling.\n* The same digits placed in the path instead of the port parse and render in 1 ms or less.\n* URI.parse(\"http://example.com:65536\").port returns 65536, and a port of 1,262,593 digits raises SystemLimitError."
}
],
"x_technicalAnalysis": [
{
"lang": "en",
"supportingMedia": [
{
"base64": false,
"type": "text/html",
"value": "\u003cp\u003e\u003cstrong\u003e1. Unbounded conversion\u003c/strong\u003e\u003c/p\u003e\n\u003cp\u003e\u003ccode\u003esplit_authority/1\u003c/code\u003e matches the port with the regex \u003ccode\u003e(:(\\d*))?\u003c/code\u003e and converts it with \u003ccode\u003eString.to_integer/1\u003c/code\u003e. Nothing bounds the digit run, so the VM builds an integer of up to 1,262,592 decimal digits (about 30 ms to parse 400,000 digits) and raises \u003ccode\u003eSystemLimitError\u003c/code\u003e above that, which escapes \u003ccode\u003eURI.parse/1\u003c/code\u003e instead of producing a \u003ccode\u003e%URI{}\u003c/code\u003e.\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003e2. Render cost\u003c/strong\u003e\u003c/p\u003e\n\u003cp\u003e\u003ccode\u003eURI.to_string/1\u003c/code\u003e renders the port with \u003ccode\u003eInteger.to_string/1\u003c/code\u003e, and \u003ccode\u003einspect/1\u003c/code\u003e renders it the same way, both through \u003ccode\u003e:erlang.integer_to_binary/1\u003c/code\u003e. That conversion is quadratic on OTP 27, 28 and 29.0 and sub-quadratic from OTP 29.1 (\u003ca href=\"https://github.com/erlang/otp/pull/11074\" rel=\"noopener noreferrer\"\u003ehttps://github.com/erlang/otp/pull/11074\u003c/a\u003e). Per GHSA-8qw4-2chm-mvj2 it occupies a dirty CPU scheduler for its full duration.\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003e3. Comparison with \u003ccode\u003eURI.new/1\u003c/code\u003e\u003c/strong\u003e\u003c/p\u003e\n\u003cp\u003eErlang/OTP limited the port to 5 bytes in \u003ccode\u003e:uri_string.parse/1\u003c/code\u003e as part of GHSA-8qw4-2chm-mvj2. \u003ccode\u003eURI.new/1\u003c/code\u003e and \u003ccode\u003eURI.new!/1\u003c/code\u003e use \u003ccode\u003e:uri_string\u003c/code\u003e and get that limit on patched OTP versions. \u003ccode\u003eURI.parse/1\u003c/code\u003e does its own parsing and does not.\u003c/p\u003e\n\u003cp\u003e\u003cstrong\u003e4. Reach\u003c/strong\u003e\u003c/p\u003e\n\u003cp\u003eHeader-sourced URLs are bounded by HTTP server limits. Form and JSON body fields are not: Plug\u0027s default JSON body limit is 8 MB. One observed library path: a user-supplied URL passed to Req inside a Bandit request exits with the port in its arguments, and Bandit\u0027s error logging formats it, which took 2.8 seconds at 400,000 digits on OTP 28.\u003c/p\u003e"
},
{
"base64": false,
"type": "text/markdown",
"value": "**1. Unbounded conversion**\n\n`split_authority/1` matches the port with the regex `(:(\\d*))?` and converts it with `String.to_integer/1`. Nothing bounds the digit run, so the VM builds an integer of up to 1,262,592 decimal digits (about 30 ms to parse 400,000 digits) and raises `SystemLimitError` above that, which escapes `URI.parse/1` instead of producing a `%URI{}`.\n\n**2. Render cost**\n\n`URI.to_string/1` renders the port with `Integer.to_string/1`, and `inspect/1` renders it the same way, both through `:erlang.integer_to_binary/1`. That conversion is quadratic on OTP 27, 28 and 29.0 and sub-quadratic from OTP 29.1 (https://github.com/erlang/otp/pull/11074). Per GHSA-8qw4-2chm-mvj2 it occupies a dirty CPU scheduler for its full duration.\n\n**3. Comparison with `URI.new/1`**\n\nErlang/OTP limited the port to 5 bytes in `:uri_string.parse/1` as part of GHSA-8qw4-2chm-mvj2. `URI.new/1` and `URI.new!/1` use `:uri_string` and get that limit on patched OTP versions. `URI.parse/1` does its own parsing and does not.\n\n**4. Reach**\n\nHeader-sourced URLs are bounded by HTTP server limits. Form and JSON body fields are not: Plug\u0027s default JSON body limit is 8 MB. One observed library path: a user-supplied URL passed to Req inside a Bandit request exits with the port in its arguments, and Bandit\u0027s error logging formats it, which took 2.8 seconds at 400,000 digits on OTP 28."
}
],
"value": "1. Unbounded conversion\n\nsplit_authority/1 matches the port with the regex (:(\\d*))? and converts it with String.to_integer/1. Nothing bounds the digit run, so the VM builds an integer of up to 1,262,592 decimal digits (about 30 ms to parse 400,000 digits) and raises SystemLimitError above that, which escapes URI.parse/1 instead of producing a %URI{}.\n\n2. Render cost\n\nURI.to_string/1 renders the port with Integer.to_string/1, and inspect/1 renders it the same way, both through :erlang.integer_to_binary/1. That conversion is quadratic on OTP 27, 28 and 29.0 and sub-quadratic from OTP 29.1 (https://github.com/erlang/otp/pull/11074). Per GHSA-8qw4-2chm-mvj2 it occupies a dirty CPU scheduler for its full duration.\n\n3. Comparison with URI.new/1\n\nErlang/OTP limited the port to 5 bytes in :uri_string.parse/1 as part of GHSA-8qw4-2chm-mvj2. URI.new/1 and URI.new!/1 use :uri_string and get that limit on patched OTP versions. URI.parse/1 does its own parsing and does not.\n\n4. Reach\n\nHeader-sourced URLs are bounded by HTTP server limits. Form and JSON body fields are not: Plug\u0027s default JSON body limit is 8 MB. One observed library path: a user-supplied URL passed to Req inside a Bandit request exits with the port in its arguments, and Bandit\u0027s error logging formats it, which took 2.8 seconds at 400,000 digits on OTP 28."
}
]
}
},
"cveMetadata": {
"assignerOrgId": "6b3ad84c-e1a6-4bf7-a703-f496b71e49db",
"assignerShortName": "EEF",
"cveId": "CVE-2026-109821",
"datePublished": "2026-10-30T08:54:07.752Z",
"dateReserved": "2026-10-23T14:00:01.560Z",
"dateUpdated": "2026-10-30T16:52:43.052Z",
"state": "PUBLISHED"
},
"dataType": "CVE_RECORD",
"dataVersion": "5.2"
}