CVE-2026-47074
Description
Improper Certificate Validation vulnerability in ex-aws ex_aws_sns (ExAws.SNS, ExAws.SNS.PublicKeyCache modules) allows Signature Spoofing by Improper Validation.
This vulnerability is associated with program files lib/ex_aws/sns.ex, lib/ex_aws/sns/public_key_cache.ex and program routines 'Elixir.ExAws.SNS':verify_message/1, 'Elixir.ExAws.SNS.PublicKeyCache':get/1.
'Elixir.ExAws.SNS':verify_message/1 fetches the signing certificate from the SigningCertURL field of the incoming SNS message without validating that the URL uses HTTPS or that the host matches an AWS-owned SNS certificate domain. An unauthenticated attacker who can POST to an endpoint that calls verify_message/1 can supply an attacker-controlled SigningCertURL, sign a forged SNS message with their own key, and cause the function to return :ok, completely bypassing SNS signature verification.
This issue affects ex_aws_sns: from 2.0.1 before 2.3.5.
AI Insight
LLM-synthesized narrative grounded in this CVE's description and references.
Missing HTTPS and hostname validation in `verify_message/1` allows an attacker to forge SNS message signatures by supplying an arbitrary signing certificate URL.
Vulnerability
In ExAws.SNS.verify_message/1 (file lib/ex_aws/sns.ex), the function fetches the signing certificate from the SigningCertURL field of an incoming SNS message without validating that the URL uses HTTPS or that the hostname matches an AWS-owned certificate domain. The PublicKeyCache.get/1 function (file lib/ex_aws/sns/public_key_cache.ex) retrieves and caches the certificate from any URL provided. This issue affects the ex_aws_sns package from version 2.0.1 before 2.3.5 [1][2][4].
Exploitation
An unauthenticated attacker who can POST to any HTTP endpoint that calls verify_message/1 can supply a forged SigningCertURL pointing to an attacker-controlled server hosting a certificate signed with their own RSA key. The attacker builds a crafted SNS notification payload, computes the canonical string-to-sign per the SNS specification, signs it with their private key, and sets the Signature field to the base64-encoded signature. Upon receiving the payload, verify_message/1 fetches the attacker's certificate from the uncontrolled URL, uses it to verify the signature, and returns :ok, treating the forged message as authentic [1][2][4].
Impact
Successful exploitation results in complete bypass of SNS signature verification. The application treats the forged SNS message as legitimate, enabling an attacker to inject arbitrary notification data, potentially leading to unintended business logic execution, data corruption, or further compromise depending on how the application processes SNS messages [1][2][4].
Mitigation
The vulnerability is fixed in ex_aws_sns version 2.3.5 [1][2]. The fix introduces certificate validation that checks the certificate subject hostname matches sns.amazonaws.com, verifies the certificate chain via AIA caIssuers with a maximum depth of 5, and validates the certificate validity period. Users should upgrade to >= 2.3.5 immediately. No workaround is available without upgrading [1][2].
AI Insight generated on May 28, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.
Affected products
2(expand)+ 1 more
- (no CPE)
- (no CPE)range: >=2.0.1 <2.3.5
Patches
11853d280b152Merge pull request #122 from ex-aws/cert_url_validation
9 files changed · +368 −90
.github/workflows/on-push.yml+14 −33 modified@@ -11,53 +11,34 @@ jobs: matrix: include: - pair: - otp: 27.x - elixir: 1.17.x + otp: "28" + elixir: "1.18" lint: lint - pair: - otp: 26.x - elixir: 1.17.x + otp: "27" + elixir: "1.18" - pair: - otp: 26.x - elixir: 1.16.x - - pair: - otp: 26.x - elixir: 1.15.x + otp: "27" + elixir: "1.17" - pair: - otp: 25.x - elixir: 1.17.x - - pair: - otp: 25.x - elixir: 1.16.x - - pair: - otp: 25.x - elixir: 1.15.x - - pair: - otp: 25.x - elixir: 1.14.x - - - pair: - otp: 24.x - elixir: 1.16.x - - pair: - otp: 24.x - elixir: 1.15.x + otp: "26" + elixir: "1.17" - pair: - otp: 24.x - elixir: 1.14.x + otp: "26" + elixir: "1.16" - pair: - otp: 24.x - elixir: 1.13.x + otp: "26" + elixir: "1.15" steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@v5 - uses: erlef/setup-beam@v1 with: otp-version: ${{matrix.pair.otp}} elixir-version: ${{matrix.pair.elixir}} - - uses: actions/cache@v3 + - uses: actions/cache@v4 with: path: | deps
lib/ex_aws/sns/cert_utility.ex+206 −0 added@@ -0,0 +1,206 @@ +defmodule ExAws.SNS.CertUtility do + @moduledoc false + + require Record + + @public_key_header "public_key/include/public_key.hrl" + Enum.each(Record.extract_all(from_lib: @public_key_header), fn {name, _} -> + macro = name |> to_string() |> String.downcase() |> String.to_atom() + Record.defrecordp(macro, name, Record.extract(name, from_lib: @public_key_header)) + end) + + @expected_sns_hostname ~c"sns.amazonaws.com" + + @aia_oid {1, 3, 6, 1, 5, 5, 7, 1, 1} + @ca_issuers_oid {1, 3, 6, 1, 5, 5, 7, 48, 2} + @max_chain_depth 5 + + @type otp_cert :: :public_key.cert() + @type http_client :: module() + + @doc "Decodes a PEM entry into an OTP certificate structure." + @spec decode_otp(:public_key.pem_entry()) :: {:ok, otp_cert()} | {:error, String.t()} + def decode_otp(pem_entry) do + try do + der = elem(pem_entry, 1) + {:ok, :public_key.pkix_decode_cert(der, :otp)} + catch + _kind, error -> {:error, "Failed to decode certificate: #{inspect(error)}"} + end + end + + @doc "Verifies the certificate's hostname matches the expected SNS hostname." + @spec validate_subject(:public_key.pem_entry()) :: :ok | {:error, String.t()} + def validate_subject(pem_entry) do + der = elem(pem_entry, 1) + + if :public_key.pkix_verify_hostname(der, dns_id: @expected_sns_hostname) do + :ok + else + {:error, "Certificate hostname does not match expected #{@expected_sns_hostname}"} + end + end + + @doc "Extracts the NotBefore and NotAfter validity datetimes from an OTP certificate." + @spec get_validity(otp_cert()) :: {:ok, DateTime.t(), DateTime.t()} | {:error, String.t()} + def get_validity(otp_cert) do + tbs = otpcertificate(otp_cert, :tbsCertificate) + {:Validity, not_before, not_after} = otptbscertificate(tbs, :validity) + + with {:ok, not_before_dt} <- parse_cert_time(not_before), + {:ok, not_after_dt} <- parse_cert_time(not_after) do + {:ok, not_before_dt, not_after_dt} + end + end + + @doc """ + Builds and validates the certificate chain up to a trusted root. + + Follows AIA caIssuers links recursively to fetch intermediate CAs, then validates + the full chain using the system trust store. Allows up to #{@max_chain_depth} intermediates. + """ + @spec build_and_validate_chain(:public_key.pem_entry(), http_client()) :: + :ok | {:error, String.t()} + def build_and_validate_chain(pem_entry, http_client) do + build_and_validate_chain(elem(pem_entry, 1), [], http_client) + end + + @doc "Extracts the RSA public key from a PEM entry." + @spec get_public_key(:public_key.pem_entry()) :: + {:ok, :public_key.rsa_public_key()} | {:error, String.t()} + def get_public_key(pem_entry) do + try do + key = + pem_entry + |> :public_key.pem_entry_decode() + |> certificate(:tbsCertificate) + |> tbscertificate(:subjectPublicKeyInfo) + |> subjectpublickeyinfo(:subjectPublicKey) + + {:ok, :public_key.der_decode(:RSAPublicKey, key)} + catch + _kind, error -> {:error, "Unexpected error while decoding public key: #{inspect(error)}"} + end + end + + defp build_and_validate_chain(current_der, intermediates, http_client) + when length(intermediates) <= @max_chain_depth do + current_otp = :public_key.pkix_decode_cert(current_der, :otp) + + :public_key.cacerts_load() + + # Chain order for pkix_path_validation: closest to root first, peer (end-entity) last. + # As we recurse upward fetching intermediates, current_der moves closer to the root, + # so it prepends the accumulated chain. + chain = [current_der | intermediates] + + root_candidates = + Enum.filter(:public_key.cacerts_get(), fn {:cert, _der, root_otp} -> + :public_key.pkix_is_issuer(current_otp, root_otp) + end) + + validated = + Enum.find_value(root_candidates, fn {:cert, root_der, _} -> + case :public_key.pkix_path_validation(root_der, chain, + verify_fun: path_validation_verify_fun() + ) do + {:ok, _} -> :ok + {:error, _} -> nil + end + end) + + cond do + validated == :ok -> + :ok + + root_candidates == [] -> + tbs = otpcertificate(current_otp, :tbsCertificate) + extensions = otptbscertificate(tbs, :extensions) + + with {:ok, aia_url} <- find_ca_issuers_url(extensions), + {:ok, next_der} <- fetch_der(aia_url, http_client) do + build_and_validate_chain(next_der, [current_der | intermediates], http_client) + end + + true -> + {:error, "Certificate chain validation failed: no trusted root found for issuer"} + end + end + + defp build_and_validate_chain(_current_der, _intermediates, _http_client) do + {:error, + "Certificate chain validation failed: exceeded maximum chain depth of #{@max_chain_depth}"} + end + + defp path_validation_verify_fun do + {fn + # SNS signing certs are TLS server certs (Digital Signature, Key Encipherment) + # and do not have the keyCertSign usage required by strict path validation. + # We accept this because we only use the public key to verify message signatures, + # not for TLS. The chain of trust is still fully validated. + _cert, {:bad_cert, :invalid_key_usage}, state -> {:valid, state} + _cert, {:bad_cert, reason}, _state -> {:fail, reason} + _cert, {:extension, _}, state -> {:unknown, state} + _cert, :valid, state -> {:valid, state} + _cert, :valid_peer, state -> {:valid, state} + end, []} + end + + defp find_ca_issuers_url(extensions) when is_list(extensions) do + case Enum.find(extensions, fn {:Extension, oid, _, _} -> oid == @aia_oid end) do + {:Extension, _, _, access_descriptions} -> + access_descriptions + |> Enum.find(&match?({:AccessDescription, @ca_issuers_oid, _}, &1)) + |> case do + {:AccessDescription, _, {:uniformResourceIdentifier, url_chars}} -> + {:ok, List.to_string(url_chars)} + + _ -> + {:error, "Certificate AIA extension does not contain a caIssuers URL"} + end + + nil -> + {:error, "Certificate does not contain an Authority Information Access extension"} + end + end + + defp find_ca_issuers_url(_), do: {:error, "Certificate has no extensions"} + + defp fetch_der(url, http_client) do + case http_client.request(:get, url) do + {:ok, %{status_code: 200, body: body}} when is_binary(body) -> + {:ok, body} + + {:ok, %{status_code: status_code}} -> + {:error, "Could not fetch intermediate CA from #{url}, got HTTP #{status_code}"} + + {:error, %{reason: reason}} -> + {:error, "Could not fetch intermediate CA from #{url}: #{inspect(reason)}"} + end + end + + defp parse_cert_time({type, time_chars}) do + time_str = List.to_string(time_chars) + + case {type, time_str} do + {:utcTime, + <<yy::binary-2, mm::binary-2, dd::binary-2, hh::binary-2, min::binary-2, ss::binary-2, + "Z">>} -> + year_2digit = String.to_integer(yy) + year = if year_2digit >= 50, do: 1900 + year_2digit, else: 2000 + year_2digit + date = Date.new!(year, String.to_integer(mm), String.to_integer(dd)) + time = Time.new!(String.to_integer(hh), String.to_integer(min), String.to_integer(ss)) + DateTime.new(date, time, "Etc/UTC") + + {:generalTime, + <<yyyy::binary-4, mm::binary-2, dd::binary-2, hh::binary-2, min::binary-2, ss::binary-2, + "Z">>} -> + date = Date.new!(String.to_integer(yyyy), String.to_integer(mm), String.to_integer(dd)) + time = Time.new!(String.to_integer(hh), String.to_integer(min), String.to_integer(ss)) + DateTime.new(date, time, "Etc/UTC") + + _ -> + {:error, "Unexpected #{type} format: #{time_str}"} + end + end +end
lib/ex_aws/sns.ex+41 −12 modified@@ -474,11 +474,32 @@ defmodule ExAws.SNS do :ok | {:error, String.t()} def verify_message(message_params) do with :ok <- validate_message_params(message_params), - :ok <- validate_signature_version(message_params["SignatureVersion"]), - {:ok, public_key} <- ExAws.SNS.PublicKeyCache.get(message_params["SigningCertURL"]) do + {:ok, hash_algo} <- validate_signature_version(message_params["SignatureVersion"]), + {:ok, {public_key, not_before, not_after}} <- + ExAws.SNS.PublicKeyCache.get(message_params["SigningCertURL"]), + :ok <- validate_timestamp(message_params["Timestamp"], not_before, not_after) do message_params - |> get_string_to_sign - |> verify(message_params["Signature"], public_key) + |> get_string_to_sign() + |> verify(message_params["Signature"], public_key, hash_algo) + end + end + + defp validate_timestamp(timestamp_str, not_before, not_after) do + case DateTime.from_iso8601(timestamp_str) do + {:ok, timestamp, _} -> + cond do + DateTime.before?(timestamp, not_before) -> + {:error, "Message Timestamp is before certificate validity period"} + + DateTime.after?(timestamp, not_after) -> + {:error, "Message Timestamp is after certificate validity period"} + + true -> + :ok + end + + {:error, _} -> + {:error, "Invalid Timestamp format: #{timestamp_str}"} end end @@ -519,20 +540,28 @@ defmodule ExAws.SNS do defp validate_signature_version(version) do case version do "1" -> - :ok + {:ok, :sha} + + "2" -> + {:ok, :sha256} val when is_binary(val) -> - {:error, "Unsupported SignatureVersion, expected \"1\", got #{version}"} + {:error, "Unsupported SignatureVersion, expected \"1\" or \"2\", got #{version}"} _ -> - {:error, "Invalid SignatureVersion format, expected a String, got #{version}"} + {:error, "Invalid SignatureVersion format, expected a String, got #{inspect(version)}"} end end defp get_string_to_sign(message_params) do - message_params - |> Map.take(get_params_to_sign(message_params["Type"])) - |> Enum.map(fn {key, value} -> [to_string(key), "\n", to_string(value), "\n"] end) + get_params_to_sign(message_params["Type"]) + |> Enum.sort() + |> Enum.flat_map(fn key -> + case Map.fetch(message_params, key) do + {:ok, value} -> [key, "\n", to_string(value), "\n"] + :error -> [] + end + end) |> IO.iodata_to_binary() end @@ -544,8 +573,8 @@ defmodule ExAws.SNS do end end - defp verify(message, signature, public_key) do - case :public_key.verify(message, :sha, Base.decode64!(signature), public_key) do + defp verify(message, signature, public_key, hash_algo) do + case :public_key.verify(message, hash_algo, Base.decode64!(signature), public_key) do true -> :ok false -> {:error, "Signature is invalid"} end
lib/ex_aws/sns/public_key_cache.ex+38 −23 modified@@ -1,13 +1,17 @@ defmodule ExAws.SNS.PublicKeyCache do use GenServer + alias ExAws.SNS.CertUtility + + @valid_sns_hosts_default ~r/^sns\.[a-z0-9-]+\.amazonaws\.com(\.cn)?$|^sns\.[a-z0-9-]+\.api\.aws$/ + def start_link(opts \\ []) do GenServer.start_link(__MODULE__, :ok, opts) end def get(cert_url) do case :ets.lookup(__MODULE__, cert_url) do - [{_cert_url, public_key}] -> {:ok, public_key} + [{_cert_url, entry}] -> {:ok, entry} [] -> GenServer.call(__MODULE__, {:get_public_key, cert_url}) end end @@ -20,20 +24,29 @@ defmodule ExAws.SNS.PublicKeyCache do end def handle_call({:get_public_key, cert_url}, _from, ets) do - with {:ok, cert} <- fetch_certificate(cert_url), - public_key <- get_public_key(cert) do - :ets.insert(__MODULE__, {cert_url, public_key}) - {:reply, {:ok, public_key}, ets} + http_client = Application.get_env(:ex_aws, :http_client, ExAws.Request.Hackney) + + with :ok <- validate_cert_url(cert_url), + {:ok, pem_entry} <- fetch_certificate(cert_url, http_client), + {:ok, otp_cert} <- CertUtility.decode_otp(pem_entry), + :ok <- CertUtility.validate_subject(pem_entry), + {:ok, not_before, not_after} <- CertUtility.get_validity(otp_cert), + :ok <- CertUtility.build_and_validate_chain(pem_entry, http_client), + {:ok, public_key} <- CertUtility.get_public_key(pem_entry) do + entry = {public_key, not_before, not_after} + :ets.insert(__MODULE__, {cert_url, entry}) + {:reply, {:ok, entry}, ets} else error -> {:reply, error, ets} end end - defp fetch_certificate(cert_url) do - http_client = Application.get_env(:ex_aws, :http_client, ExAws.Request.Hackney) - + defp fetch_certificate(cert_url, http_client) do with {:ok, %{status_code: 200, body: cert_binary}} <- http_client.request(:get, cert_url) do - get_pem_entry(:public_key.pem_decode(cert_binary)) + case :public_key.pem_decode(cert_binary) do + [entry] -> {:ok, entry} + entries -> {:error, "Invalid PEM entries: #{inspect(entries)}"} + end else {:ok, %{status_code: status_code}} -> {:error, @@ -45,21 +58,23 @@ defmodule ExAws.SNS.PublicKeyCache do end end - defp get_pem_entry(pem_entries) do - case pem_entries do - [entry] -> - try do - {:ok, :public_key.pem_entry_decode(entry)} - catch - _kind, error -> {:error, "Unexpected error while decoding pem entry: #{inspect(error)}"} - end + defp validate_cert_url(cert_url) do + allowed_cert_schemes = Application.get_env(:ex_aws_sns, :allowed_cert_schemes, ["https"]) + valid_hosts = Application.get_env(:ex_aws_sns, :valid_hosts, @valid_sns_hosts_default) + uri = URI.parse(cert_url) - entries -> - {:error, "Invalid PEM entries: #{inspect(entries)}"} - end - end + cond do + uri.scheme not in allowed_cert_schemes -> + {:error, "Invalid certificate URL scheme: #{cert_url}"} + + not String.ends_with?(uri.path || "", ".pem") -> + {:error, "Invalid SNS certificate URL path, expected .pem extension: #{uri.path}"} - defp get_public_key(cert) do - :public_key.der_decode(:RSAPublicKey, cert |> elem(1) |> elem(7) |> elem(2)) + !(uri.host =~ valid_hosts) -> + {:error, "Invalid SNS certificate URL host: #{uri.host}"} + + true -> + :ok + end end end
mix.exs+2 −2 modified@@ -11,7 +11,7 @@ defmodule ExAws.SNS.Mixfile do app: :ex_aws_sns, name: @name, version: @version, - elixir: "~> 1.13", + elixir: "~> 1.15", elixirc_paths: elixirc_paths(Mix.env()), start_permanent: Mix.env() == :prod, deps: deps(), @@ -38,7 +38,7 @@ defmodule ExAws.SNS.Mixfile do def application do [ - extra_applications: [:logger], + extra_applications: [:logger, :public_key], mod: {ExAws.SNS.Application, []} ] end
mix.lock+9 −7 modified@@ -1,25 +1,27 @@ %{ - "certifi": {:hex, :certifi, "2.15.0", "0e6e882fcdaaa0a5a9f2b3db55b1394dba07e8d6d9bcad08318fb604c6839712", [:rebar3], [], "hexpm", "b147ed22ce71d72eafdad94f055165c1c182f61a2ff49df28bcc71d1d5b94a60"}, - "configparser_ex": {:hex, :configparser_ex, "4.0.0", "17e2b831cfa33a08c56effc610339b2986f0d82a9caa0ed18880a07658292ab6", [:mix], [], "hexpm", "02e6d1a559361a063cba7b75bc3eb2d6ad7e62730c551cc4703541fd11e65e5b"}, + "certifi": {:hex, :certifi, "2.16.0", "a4edfc1d2da3424d478a3271133bf28e0ec5e6fd8c009aab5a4ae980cb165ce9", [:rebar3], [], "hexpm", "8a64f6669d85e9cc0e5086fcf29a5b13de57a13efa23d3582874b9a19303f184"}, + "configparser_ex": {:hex, :configparser_ex, "5.0.0", "2b37c5022500fc0a47245dad7c2a8df8d2b41441ed348c162de422965a787d0c", [:mix], [], "hexpm", "404319c6181d38e7cf2f6c4b8ce6d6cdaebbde5538a047ec29937cf7198b6b29"}, "dialyxir": {:hex, :dialyxir, "1.4.7", "dda948fcee52962e4b6c5b4b16b2d8fa7d50d8645bbae8b8685c3f9ecb7f5f4d", [:mix], [{:erlex, ">= 0.2.8", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "b34527202e6eb8cee198efec110996c25c5898f43a4094df157f8d28f27d9efe"}, "earmark": {:hex, :earmark, "1.2.3", "206eb2e2ac1a794aa5256f3982de7a76bf4579ff91cb28d0e17ea2c9491e46a4", [:mix], [], "hexpm", "3b1dcad3067985dd8618c38399a8ee9c4e652d52a17a4aae7a6d6fc4fcc24856"}, "earmark_parser": {:hex, :earmark_parser, "1.4.44", "f20830dd6b5c77afe2b063777ddbbff09f9759396500cdbe7523efd58d7a339c", [:mix], [], "hexpm", "4778ac752b4701a5599215f7030989c989ffdc4f6df457c5f36938cc2d2a2750"}, "erlex": {:hex, :erlex, "0.2.9", "7debbbaa9f4f368b8cd648983e0f1d7963028508e9c59e9d4ed504e94ef52a55", [:mix], [], "hexpm", "8cfffc0ec7159e6d73de2ab28a588064de80f88b2798d5cbe4482cbbc200178b"}, - "ex_aws": {:hex, :ex_aws, "2.5.9", "8e2455172f0e5cbe2f56dd68de514f0dae6bb26d6b6e2f435a06434cf9dbb412", [:mix], [{:configparser_ex, "~> 4.0", [hex: :configparser_ex, repo: "hexpm", optional: true]}, {:hackney, "~> 1.16", [hex: :hackney, repo: "hexpm", optional: true]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: true]}, {:jsx, "~> 2.8 or ~> 3.0", [hex: :jsx, repo: "hexpm", optional: true]}, {:mime, "~> 1.2 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:req, "~> 0.5.10 or ~> 0.6 or ~> 1.0", [hex: :req, repo: "hexpm", optional: true]}, {:sweet_xml, "~> 0.7", [hex: :sweet_xml, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "cbdb6ffb0e6c6368de05ed8641fe1376298ba23354674428e5b153a541f23359"}, + "ex_aws": {:hex, :ex_aws, "2.7.0", "e6bfd4b5fb8c791aa6c7d57fc7c45f050bb89de9576f1f6104aa974b234c01ec", [:mix], [{:configparser_ex, "~> 5.0", [hex: :configparser_ex, repo: "hexpm", optional: true]}, {:hackney, "~> 4.0", [hex: :hackney, repo: "hexpm", optional: true]}, {:jason, "~> 1.1", [hex: :jason, repo: "hexpm", optional: true]}, {:jsx, "~> 2.8 or ~> 3.0", [hex: :jsx, repo: "hexpm", optional: true]}, {:mime, "~> 1.2 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:req, "~> 0.5.10 or ~> 0.6 or ~> 1.0", [hex: :req, repo: "hexpm", optional: true]}, {:sweet_xml, "~> 0.7", [hex: :sweet_xml, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "bfe9d744d4fd4c1f40314ee7fab504d5547d1f01cd377fff1568cbe630b06d65"}, "ex_doc": {:hex, :ex_doc, "0.40.3", "4a972ffe64bc07dc605af487e98fc19b72a4185f55ca031b94c0552d6071c1d9", [:mix], [{:earmark_parser, "~> 1.4.44", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_c, ">= 0.1.0", [hex: :makeup_c, repo: "hexpm", optional: true]}, {:makeup_elixir, "~> 0.14 or ~> 1.0", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1 or ~> 1.0", [hex: :makeup_erlang, repo: "hexpm", optional: false]}, {:makeup_html, ">= 0.1.0", [hex: :makeup_html, repo: "hexpm", optional: true]}], "hexpm", "2756e357742fecd9749b489b85d67c9ce99c465f2e75728d9e6dc8d704b973de"}, - "hackney": {:hex, :hackney, "1.25.0", "390e9b83f31e5b325b9f43b76e1a785cbdb69b5b6cd4e079aa67835ded046867", [:rebar3], [{:certifi, "~> 2.15.0", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "~> 6.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "~> 1.0.0", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~> 1.4", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.4.1", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~> 1.1.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}, {:unicode_util_compat, "~> 0.7.1", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "7209bfd75fd1f42467211ff8f59ea74d6f2a9e81cbcee95a56711ee79fd6b1d4"}, - "idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~>0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"}, + "h2": {:hex, :h2, "0.6.0", "32b519f7a5a16ffb699c5ef9784ad279f3d1aa45b563e3328b996c8db64c27d4", [:rebar3], [], "hexpm", "5ac1c05675efecacbb8014d3923b3a19b1f6c4672f2c103ac24d563169338888"}, + "hackney": {:hex, :hackney, "4.0.2", "c30c995c576679d4a39e42ca6013852abdd4fdc0fc97bdfb1437651f3d2ada4e", [:rebar3], [{:certifi, "~> 2.16.0", [hex: :certifi, repo: "hexpm", optional: false]}, {:h2, "0.6.0", [hex: :h2, repo: "hexpm", optional: false]}, {:idna, "~> 7.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:mimerl, "~> 1.4", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.4.1", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:quic, "1.4.3", [hex: :quic, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~> 1.1.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}], "hexpm", "83774852eb40f70023fe88b625907148e8d37313c55512f5ab400513fde81d4d"}, + "idna": {:hex, :idna, "7.1.0", "1067a13043538129602d2f2ce6899d8713125c7d19734aa557ce2e3ea55bd4f1", [:rebar3], [], "hexpm", "6ae959a025bf36df61a8cab8508d9654891b5426a84c44d82deaffd6ddf8c71f"}, "makeup": {:hex, :makeup, "1.2.1", "e90ac1c65589ef354378def3ba19d401e739ee7ee06fb47f94c687016e3713d1", [:mix], [{:nimble_parsec, "~> 1.4", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "d36484867b0bae0fea568d10131197a4c2e47056a6fbe84922bf6ba71c8d17ce"}, "makeup_elixir": {:hex, :makeup_elixir, "1.0.1", "e928a4f984e795e41e3abd27bfc09f51db16ab8ba1aebdba2b3a575437efafc2", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "7284900d412a3e5cfd97fdaed4f5ed389b8f2b4cb49efc0eb3bd10e2febf9507"}, "makeup_erlang": {:hex, :makeup_erlang, "1.1.0", "835f7e60792e08824cda445639555d7bf1bbbddb1b60b306e33cb6f6db24dc74", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "1cd6780fb1dd1a03979abaed0fe82712b0625118fd5257d3ebbf73f960c73c3c"}, "metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm", "69b09adddc4f74a40716ae54d140f93beb0fb8978d8636eaded0c31b6f099f16"}, - "mime": {:hex, :mime, "2.0.6", "8f18486773d9b15f95f4f4f1e39b710045fa1de891fada4516559967276e4dc2", [:mix], [], "hexpm", "c9945363a6b26d747389aac3643f8e0e09d30499a138ad64fe8fd1d13d9b153e"}, + "mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"}, "mimerl": {:hex, :mimerl, "1.5.0", "f35aca6f23242339b3666e0ac0702379e362b469d0aea167f6cc713547e777ed", [:rebar3], [], "hexpm", "db648ce065bae14ea84ca8b5dd123f42f49417cef693541110bf6f9e9be9ecc4"}, "nimble_parsec": {:hex, :nimble_parsec, "1.4.2", "8efba0122db06df95bfaa78f791344a89352ba04baedd3849593bfce4d0dc1c6", [:mix], [], "hexpm", "4b21398942dda052b403bbe1da991ccd03a053668d147d53fb8c4e0efe09c973"}, "parse_trans": {:hex, :parse_trans, "3.4.1", "6e6aa8167cb44cc8f39441d05193be6e6f4e7c2946cb2759f015f8c56b76e5ff", [:rebar3], [], "hexpm", "620a406ce75dada827b82e453c19cf06776be266f5a67cff34e1ef2cbb60e49a"}, "poison": {:hex, :poison, "6.0.0", "9bbe86722355e36ffb62c51a552719534257ba53f3271dacd20fbbd6621a583a", [:mix], [{:decimal, "~> 2.1", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "bb9064632b94775a3964642d6a78281c07b7be1319e0016e1643790704e739a2"}, + "quic": {:hex, :quic, "1.4.3", "cca828db522ac67b638081d93562b463e84286c22655c3409c5746c302e177e5", [:rebar3], [], "hexpm", "a12ffedc8ad8a41d303083c306dea76141fab3a54df8dc0c5e5691fa40e8e619"}, "ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.7", "354c321cf377240c7b8716899e182ce4890c5938111a1296add3ec74cf1715df", [:make, :mix, :rebar3], [], "hexpm", "fe4c190e8f37401d30167c8c405eda19469f34577987c76dde613e838bbc67f8"}, "sweet_xml": {:hex, :sweet_xml, "0.7.5", "803a563113981aaac202a1dbd39771562d0ad31004ddbfc9b5090bdcd5605277", [:mix], [], "hexpm", "193b28a9b12891cae351d81a0cead165ffe67df1b73fe5866d10629f4faefb12"}, - "telemetry": {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"}, + "telemetry": {:hex, :telemetry, "1.4.2", "a0cb522801dffb1c49fe6e30561badffc7b6d0e180db1300df759faa22062855", [:rebar3], [], "hexpm", "928f6495066506077862c0d1646609eed891a4326bee3126ba54b60af61febb1"}, "unicode_util_compat": {:hex, :unicode_util_compat, "0.7.1", "a48703a25c170eedadca83b11e88985af08d35f37c6f664d6dcfb106a97782fc", [:rebar3], [], "hexpm", "b3a917854ce3ae233619744ad1e0102e05673136776fb2fa76234f3e03b23642"}, }
test/lib/sns/cert_utility_test.exs+19 −0 added@@ -0,0 +1,19 @@ +defmodule ExAws.SNS.CertUtilityTest do + use ExUnit.Case, async: true + + alias ExAws.SNS.CertUtility + + # This cert has TLS key usage (Digital Signature, Key Encipherment) without + # keyCertSign, which strict PKIX path validation rejects as :invalid_key_usage. + # Requires network access to fetch the cert and its intermediate CA via AIA. + @tag :skip + test "build_and_validate_chain/2 succeeds for SNS cert with TLS-only key usage" do + url = + "https://sns.us-east-1.amazonaws.com/SimpleNotificationService-7506a1e35b36ef5a444dd1a8e7cc3ed8.pem" + + {:ok, body} = ExAws.Request.Hackney.request(:get, url) + [pem_entry] = :public_key.pem_decode(body.body) + + assert :ok == CertUtility.build_and_validate_chain(pem_entry, ExAws.Request.Hackney) + end +end
test/lib/sns_test.exs+37 −11 modified@@ -537,8 +537,15 @@ defmodule ExAws.SNSTest do describe "verify_message/1" do setup [:add_verify_message] - test "validate a pristine message from SNS", %{verify_message: message} do + test "validate a pristine message from SNS and check that the key is cached", %{ + verify_message: message + } do assert :ok == SNS.verify_message(message) + + url = message["SigningCertURL"] + + assert [{^url, {{:RSAPublicKey, _, _}, _not_before, _not_after}}] = + :ets.lookup(ExAws.SNS.PublicKeyCache, url) end test "fails with tampered message", %{verify_message: message} do @@ -549,28 +556,47 @@ defmodule ExAws.SNSTest do assert {:error, _message} = SNS.verify_message(message |> Map.delete("Timestamp")) end - test "fails with an invalid signature version", %{verify_message: message} do - assert {:error, _message} = SNS.verify_message(message |> Map.put("SignatureVersion", "2")) + test "fails with an unsupported signature version", %{verify_message: message} do + assert {:error, _message} = SNS.verify_message(message |> Map.put("SignatureVersion", "3")) + end + + test "fails with invalid certificate URL", %{verify_message: message} do + assert {:error, _message} = + SNS.verify_message( + message + |> Map.put("SigningCertURL", "http://example.com/cert.pem") + ) + end + + test "fails with invalid certificate scheme", %{verify_message: message} do + assert {:error, _message} = + SNS.verify_message( + message + |> Map.put( + "SigningCertURL", + String.replace(message["SigningCertURL"], "https", "http") + ) + ) end end defp add_verify_message(context) do message = %{ "Type" => "SubscriptionConfirmation", - "MessageId" => "c98bfcda-56fc-459e-9257-88b9553e22d7", + "MessageId" => "72e8378d-e4e4-4a17-b3ef-89baa9c6b615", "Token" => - "2336412f37fb687f5d51e6e241d44a2dc0dc1808be349325be7bdd46c777d7461673f5800c81ae6d5ec8e7ff8e24985fadefa80d9d9471fdf3091a6c239105468b29615b925b53382a5b69a53872116c1d1dc3af2122db5399d6be5cea19ef72aa09a8309e00f296e4e461561bb2397d", - "TopicArn" => "arn:aws:sns:eu-west-1:511293508251:ex_aws_test", + "2336412f37fb687f5d51e6e2425929f528fce8aa114aa3c70e8756de60608f0bd9e37d5388ff22d52a04ce89a27efa4104bcc2e8233ff7a9e81294fe7b0cd1df7b23e2133b3bfc7a5ad4d12a0989c8f3c340ef78035d2026701832154651e13f8e8acb64a48f8a6647ffd022a1250648", + "TopicArn" => "arn:aws:sns:us-east-1:206176965479:ex_aws_test", "Message" => - "You have chosen to subscribe to the topic arn:aws:sns:eu-west-1:511293508251:ex_aws_test.\nTo confirm the subscription, visit the SubscribeURL included in this message.", + "You have chosen to subscribe to the topic arn:aws:sns:us-east-1:206176965479:ex_aws_test.\nTo confirm the subscription, visit the SubscribeURL included in this message.", "SubscribeURL" => - "https://sns.eu-west-1.amazonaws.com/?Action=ConfirmSubscription&TopicArn=arn:aws:sns:eu-west-1:511293508251:ex_aws_test&Token=2336412f37fb687f5d51e6e241d44a2dc0dc1808be349325be7bdd46c777d7461673f5800c81ae6d5ec8e7ff8e24985fadefa80d9d9471fdf3091a6c239105468b29615b925b53382a5b69a53872116c1d1dc3af2122db5399d6be5cea19ef72aa09a8309e00f296e4e461561bb2397d", - "Timestamp" => "2016-11-16T01:52:21.709Z", + "https://sns.us-east-1.amazonaws.com/?Action=ConfirmSubscription&TopicArn=arn:aws:sns:us-east-1:206176965479:ex_aws_test&Token=2336412f37fb687f5d51e6e2425929f528fce8aa114aa3c70e8756de60608f0bd9e37d5388ff22d52a04ce89a27efa4104bcc2e8233ff7a9e81294fe7b0cd1df7b23e2133b3bfc7a5ad4d12a0989c8f3c340ef78035d2026701832154651e13f8e8acb64a48f8a6647ffd022a1250648", + "Timestamp" => "2026-05-20T05:42:47.203Z", "SignatureVersion" => "1", "Signature" => - "lAUpZvBd+bA0bBFOl//ZR8+Ud1tMQR9QDiRSS0VrCPIaY67zURqTeRhmSQFBVsqGOBM+MHnCinDC5HttGGv9L2N15urvj3L5YfZOA87TLvHPJzxiA2XCD40lrSFBRDGhO7jq49hwY48K56jik9CFiMw84jLxKMrdw9KkHYAyWt12NiZWoLWa/PHbT7tmlh1+Tkc5EN4u/t3tGwS4bSZOXWq0DIKh+rE7U84Yxyph9R9ykEArEAwXEiGBfkleXpFB4AtF4PMmXXETnBI770v24LWgsopVUIBV+p1jEJi1Mcg9D/+00BnkAFFq4S0Foryr7xA/mgPZJNTlV2nK7eaQ4g==", + "Lc5HVdFIcnNny+IpGawrITsugH0m4AfGJUKm8vypd1Mt7Ly/EcnajiUsNa515gj3wBuy/EYXl8EyE+HAsCII7JfKXy8Ho6mt7RFoFP8r+nfBhipTOPipyyQdsFVofQnV3YpM6gx4Cv9LRlE2CCDp4j7wbL1AejNRwuDBXctbNMd/OGMtodOEsyBJ8gFqgPn5LmLEcxwtN07lPx0mO+CTct1cinzoigQo7KXNrklj3iW4L2jTgt8VsxDEAW3/sGaHaujLMeW2kft+3ebGgxBECkoTUkhwqmbXkxIB3c/TAVNvX+svz29Z3zNXoXtB3GOgeEC00/IoftBmISNOcA7B7g==", "SigningCertURL" => - "https://sns.eu-west-1.amazonaws.com/SimpleNotificationService-b95095beb82e8f6a046b3aafc7f4149a.pem" + "https://sns.us-east-1.amazonaws.com/SimpleNotificationService-7506a1e35b36ef5a444dd1a8e7cc3ed8.pem" } context |> Map.put(:verify_message, message)
.tool-versions+2 −2 modified@@ -1,2 +1,2 @@ -elixir 1.17.2 -erlang 27.0.1 +elixir 1.19.5-otp-28 +erlang 28.5
Vulnerability mechanics
Root cause
"Missing validation of the SigningCertURL field allows an attacker to supply an arbitrary certificate URL, enabling complete SNS signature verification bypass."
Attack vector
An unauthenticated attacker who can POST to any endpoint that calls `ExAws.SNS.verify_message/1` can supply an attacker-controlled `SigningCertURL` [ref_id=1]. The attacker generates their own RSA keypair, hosts the public certificate at a reachable URL, builds a forged SNS message payload, signs it with their private key, and sets `SigningCertURL` to their own certificate URL [ref_id=1]. Because the library performs no validation that the URL uses HTTPS or that the host matches an AWS-owned SNS certificate domain, `verify_message/1` fetches the attacker's certificate and verifies the signature against the attacker's public key, returning `:ok` [ref_id=1].
Affected code
The vulnerability resides in `lib/ex_aws/sns.ex` (function `verify_message/1`) and `lib/ex_aws/sns/public_key_cache.ex` (function `get/1`). `verify_message/1` fetches the signing certificate from the `SigningCertURL` field without validating the URL scheme or hostname [ref_id=1]. `PublicKeyCache.get/1` fetches whatever URL is provided and caches the certificate without any hostname or scheme checks [ref_id=1].
What the fix does
The patch introduces a new `ExAws.SNS.CertUtility` module and adds validation steps in `PublicKeyCache.get/1` and `verify_message/1` [patch_id=2897468]. `PublicKeyCache` now calls `validate_cert_url/1` which checks that the URL scheme is HTTPS, the host matches a regex for AWS SNS domains (`sns.<region>.amazonaws.com(.cn)?` or `sns.<region>.api.aws`), and the path ends in `.pem` [patch_id=2897468]. Additionally, the patch adds certificate subject hostname validation via `CertUtility.validate_subject/1`, certificate validity period checking via `CertUtility.get_validity/2`, and full certificate chain validation via `CertUtility.build_and_validate_chain/2` which follows AIA caIssuers links and validates against the system trust store [patch_id=2897468]. The `verify_message/1` function also now validates that the message `Timestamp` falls within the certificate's validity period [patch_id=2897468].
Preconditions
- configThe application must expose an HTTP endpoint that calls ExAws.SNS.verify_message/1 on incoming request bodies (the standard SNS webhook pattern)
- networkAttacker must be able to POST arbitrary payloads to that endpoint
- authNo authentication or special configuration on the attacker side is required
Reproduction
1. Generate an RSA keypair and host the DER/PEM public certificate at any URL reachable from the target server (e.g. `http://attacker.example/cert.pem`). 2. Build a forged Notification payload with an arbitrary `TopicArn` and `Message`, compute the canonical string-to-sign per the SNS spec, and sign it with the attacker private key. 3. Set `SigningCertURL` to the attacker URL and `Signature` to the base64-encoded signature. 4. POST the forged payload to any SNS webhook endpoint that calls `ExAws.SNS.verify_message/1`. The function returns `:ok`; the application treats the message as authentic [ref_id=1].
Generated on May 28, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
4News mentions
0No linked articles in our index yet.