Search
Find a vulnerability
Search criteria
2 vulnerabilities by deadtrickster
CVE-2026-117666 (GCVE-0-2026-117666)
Vulnerability from nvd – Published: 2026-10-30 20:22 – Updated: 2026-10-30 20:22
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
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": {
"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-10-30T20:22:16.466Z",
"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-10-30 20:22
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
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": {
"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-10-30T20:22:16.466Z",
"state": "PUBLISHED"
},
"dataType": "CVE_RECORD",
"dataVersion": "5.2"
}