VYPR
High severityNVD Advisory· Published May 28, 2026

CVE-2026-47074

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
  • Ex Aws/Ex Aws Snsreferences2 versions
    (expand)+ 1 more
    • (no CPE)
    • (no CPE)range: >=2.0.1 <2.3.5

Patches

1
1853d280b152

Merge pull request #122 from ex-aws/cert_url_validation

https://github.com/ex-aws/ex_aws_snsBernard DugganMay 28, 2026via body-scan
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

4

News mentions

0

No linked articles in our index yet.