Introduction
Countless applications rely on Amazon Web Services’ Simple Notification Service for application-to-application communication such as webhooks and callbacks. To verify the authenticity of these messages, these projects use certificate-based signature validation based on the SigningCertURL value. Unfortunately, a loophole in official AWS SDKs allowed attackers to forge messages to all SNS HTTP subscribers.
Keep It Simple, Stupid
Amazon Simple Notification Service (SNS) is one of those technologies that has become so widespread as to be almost foundational in the serverless ecosystem today. The idea itself is not new: publisher-subscriber messaging software has existed for a long time, from Apache Kafka to RabbitMQ. SNS allows different applications to communicate remotely by delivering messages between them. While other messaging software often includes queues, SNS keeps it as simple as possible (AWS sells queue functionality separately as Amazon Simple Queue Service - yeah…). At the same time, it’s a lot more useful than simply implementing a simple HTTP webhook yourself - SNS supports FIFO messaging, easy scalability, wide publisher/subscriber support, message filtering, and more.
A typical Amazon SNS use case is the application-to-application fanout pattern. For example, an image upload and conversion workflow can be abstracted into an S3 ingest/upload event notification message that’s published to an SNS topic. The SNS topic forwards this message to several different AWS Lambda subscribers that convert the image into different formats and sizes before storing them in separate S3 buckets.
In short, SNS acts as the glue (not to be confused with AWS Glue - yeah…) between various event sources and destinations. Some of the most popular destinations supported by SNS are email, SMS, and HTTP/S - for example, notifying developers if a user has unsubscribed from their app or triggering a webhook workflow.
How the Sausage Gets Made
Of course, behind-the-scenes SNS is basically a more feature-filled HTTP webhook service. When you configure SNS to publish to an HTTP/S destination, it sends the following request to your endpoint:
POST / HTTP/1.1x-amz-sns-message-type: Notificationx-amz-sns-message-id: 22b80b92-fdea-4c2c-8f9d-bdfb0c7bf324x-amz-sns-topic-arn: arn:aws:sns:us-west-2:123456789012:MyTopicx-amz-sns-subscription-arn: arn:aws:sns:us-west-2:123456789012:MyTopic:c9135db0-26c4-47ec-8998-413945fb5a96Content-Length: 773Content-Type: text/plain; charset=UTF-8Host: example.comConnection: Keep-AliveUser-Agent: Amazon Simple Notification Service Agent { "Type" : "Notification", "MessageId" : "22b80b92-fdea-4c2c-8f9d-bdfb0c7bf324", "TopicArn" : "arn:aws:sns:us-west-2:123456789012:MyTopic", "Subject" : "My First Message", "Message" : "Hello world!", "Timestamp" : "2012-05-02T00:54:06.655Z", "SignatureVersion" : "1", "Signature" : "EXAMPLEw6JRN...", "SigningCertURL" : "https://sns.us-west-2.amazonaws.com/SimpleNotificationService-f3ecfb7224c7233fe7bb5f59f96de52f.pem", "UnsubscribeURL" : "https://sns.us-west-2.amazonaws.com/?Action=Unsubscribe&SubscriptionArn=arn:aws:sns:us-west-2:123456789012:MyTopic:c9135db0-26c4-47ec-8998-413945fb5a96"}
Depending on your event source, the subject and message contain the data you need. However, this begs the question: if your webhook endpoint is public, how do you trust that the requests you are receiving are coming from Amazon SNS? After all:
Almost no one got JWT tokens and webhooks right on the first try. With webhooks, people almost always forgot to authenticate incoming requests…”
Ken Kantzer, “Learnings from 5 years of tech startup code audits”
This is where the Signature and SigningCertURL values come into play. The AWS documentation has a straightforward description of the verification algorithm; relevant name-value pairs (such as Subject and Message) are extracted from the JSON body and arranged in a canonical format, then hashed using SHA1 to create the derived hash value.
Next, the Signature value is base64-decoded, then decrypted using the public key downloaded from SigningCertURL to create the asserted hash value.
Finally, the asserted and derived hash values are compared to ensure that they match. This is a standard signature verification scheme that Computerphile explains very well.
At this point you might have spotted a possible weakness: we are supposed to use the certificate at SigningCertURL to generate the supposedly correct hash value, but how do we trust SigningCertURL? Ay, there’s the rub…
Trust, but Verify
The AWS knowledge center provides this answer:
To help prevent spoofing attacks, make sure that you do the following when verifying Amazon SNS message signatures:
Always use HTTPS to get the certificate from Amazon SNS.
Validate the authenticity of the certificate.
Verify that the certificate was sent from Amazon SNS.
(When possible) Use one of the supported AWS SDKs for Amazon SNS to validate and verify messages.
Okay, so it tells us to Verify that the certificate was sent from Amazon SNS without telling us how. It also provides an example Python script that performs the signature validation *without verifying SigningCertURL!
import base64from M2Crypto import EVP, RSA, X509import requestscache = dict() # Extract the name-value pairs from the JSON document in the body of the HTTP POST request that Amazon SNS sent to your endpoint.def processMessage(messagePayload): print ("Start!") if (messagePayload["SignatureVersion"] != "1"): print("Unexpected signature version. Unable to verify signature.") return False messagePayload["TopicArn"] = messagePayload["TopicArn"].replace(" ", "") signatureFields = fieldsForSignature(messagePayload["Type"]) print(signatureFields) strToSign = getSignatureFields(messagePayload, signatureFields) print(strToSign) certStr = getCert(messagePayload) print("Printing the cert") print(certStr.text) print("Using M2Crypto") # Get the X509 certificate that Amazon SNS used to sign the message. certificateSNS = X509.load_cert_string(certStr.text) #Extract the public key from the certificate. public_keySNS = certificateSNS.get_pubkey() public_keySNS.reset_context(md = "sha1") # Generate the derived hash value of the Amazon SNS message. # Generate the asserted hash value of the Amazon SNS message. public_keySNS.verify_init() public_keySNS.verify_update(strToSign.encode()) # Decode the Signature value decoded_signature = base64.b64decode(messagePayload["Signature"]) # Verify the authenticity and integrity of the Amazon SNS message verification_result = public_keySNS.verify_final(decoded_signature) print("verification_result", verification_result) if verification_result != 1: print("Signature could not be verified") return False else: return True # Obtain the fields for signature based on message type.def fieldsForSignature(type): if (type == "SubscriptionConfirmation" or type == "UnsubscribeConfirmation"): return ["Message", "MessageId", "SubscribeURL", "Timestamp", "Token", "TopicArn", "Type"] elif (type == "Notification"): return ["Message", "MessageId", "Subject", "Timestamp", "TopicArn", "Type"] else: return [] # Create the string to sign. def getSignatureFields(messagePayload, signatureFields): signatureStr = "" for key in signatureFields: if key in messagePayload: signatureStr += (key + "\n" + messagePayload[key] + "\n") return signatureStr # ****Certificate Fetching**** #Certificate cachingdef get_cert_from_server(url): print("Fetching cert from server...") response = requests.get(url) return response def get_cert(url): print("Getting cert...") if url not in cache: cache[url] = get_cert_from_server(url) return cache[url] def getCert(messagePayload): certLoc = messagePayload["SigningCertURL"].replace(" ", "") print("Cert location", certLoc) responseCert = get_cert(certLoc) return responseCert
The SigningCertURL/url value is simply fetched directly without any verification step. Meanwhile, if we turn to StackOverflow,the top Google result advises verifying that the URL matches the format sns.${region}.amazonaws.com. Sensible enough, but there are various subtleties to take note of.
For example, a developer may want to verify that the URL starts with https://, includes sns as the first slug in the subdomain, includes a valid region string in the second slug, then ends with the amazonaws.com domain. Furthermore, the developer may want to ensure that the path ends with the .pem extension.
On the surface, this looks sufficient to ensure that the URL indeed belongs to one of the default AWS certificate locations. However, there is one critical loophole: Amazon S3.
More specifically, Amazon S3 bucket resources can be accessed at https://.s3-.amazonaws.com/, which easily passes the checks described above! By uploading their own generated public key at https://mysns.s3-us-west-2.amazonaws.com/evil.pem, an attacker can easily forge a signed SNS message to the victim’s webhook endpoint.
In a quick open-source code review of custom SNS SigningCertURL validation routines, on top of such broken algorithms, I also found weak regexes such as https?://sns.(.+).amazonaws.com (bypassed by http://sns.evil.s3.amazonaws.com/evil.pem) and sns.[a-z0-9-]+.amazonaws.com (bypassed by snsthisismyamazonaws.com).
Okay, so URL verification is hard. However, AWS helpfully provides AWS SDKs for Amazon SNS to validate and verify messages. For example, AWS publishes thesns-validator package on NPM. The package code uses the following regex:
defaultHostPattern = /^sns\.[a-zA-Z0-9\-]{3,}\.amazonaws\.com(\.cn)?$/,// hostPattern defaults to defaultHostPatternvar validateUrl = function (urlToValidate, hostPattern) { var parsed = url.parse(urlToValidate); return parsed.protocol === 'https:' && parsed.path.substr(-4) === '.pem' && hostPattern.test(parsed.host);};
^sns. checks that the first slug matches rather than include sns; this blocks bucket names such as mysns. However, the rest of the regex still allows for the .s3-us-west-2.amazonaws.com subdomain suffix. Fortunately, s3-us-west-2.amazonaws.com works (worked?) just the same as s3.amazonaws.com, therefore passing the minimum 3 character requirement for the second slug in the domain ([a-zA-Z0-9-]{3,}). This leaves the sns S3 bucket as the only possible match for this regex.
Problem 1: sns is not reserved.
Problem 2: sns is a publicly-readable bucket.
Problem 3: sns is a publicly-writable bucket.
Through this loophole, an attacker could forge messages to any official SDK SNS validator user. The impact depends on the application’s webhook handler. For example, Firefox Monitor, a tool that allows users to register their emails to monitor for online data breaches, has a publicly-accessible SNS webhook endpoint at https://monitor.firefox.com/ses/notification that uses an sns-validator to validate incoming POST messages:
'use strict'const MessageValidator = require('sns-validator')const DB = require('../db/DB')const mozlog = require('../log')const validator = new MessageValidator()const log = mozlog('controllers.ses')async function notification (req, res) { const message = JSON.parse(req.body) return new Promise((resolve, reject) => { validator.validate(message, async (err, message) => { if (err) { log.error('notification', { err }) const body = 'Access denied. ' + err.message res.status(401).send(body) return reject(body) } await handleNotification(message) res.status(200).json( { status: 'OK' } ) return resolve('OK') }) })}
After validating the message, it then deletes users from the database using values taken from the validated message:
async function handleNotification (notification) { log.info('received-SNS', { id: notification.MessageId }) const message = JSON.parse(notification.Message) if (message.hasOwnProperty('eventType')) { await handleSESMessage(message) } if (message.hasOwnProperty('event')) { await handleFxAMessage(message) }}async function handleFxAMessage (message) { switch (message.event) { case 'delete': await handleDeleteMessage(message) break default: log.info('unhandled-event', { event: message.event }) }}async function handleDeleteMessage (message) { await DB.deleteSubscriberByFxAUID(message.uid)}async function handleSESMessage (message) { switch (message.eventType) { case 'Bounce': await handleBounceMessage(message) break case 'Complaint': await handleComplaintMessage(message) break default: log.info('unhandled-eventType', { type: message.eventType }) }}async function handleBounceMessage (message) { const bounce = message.bounce if (bounce.bounceType === 'Permanent') { return await removeSubscribersFromDB(bounce.bouncedRecipients) }}async function handleComplaintMessage (message) { const complaint = message.complaint return await removeSubscribersFromDB(complaint.complainedRecipients)}async function removeSubscribersFromDB (recipients) { for (const recipient of recipients) { await DB.removeEmail(recipient.emailAddress) }}
As such, an attacker could delete arbitrary users from the Firefox Monitor database by forging SNS messages.
Patching Up
After reporting, I was impressed by AWS’ response. They quickly resolved the vulnerability through an elegant solution on their infrastructure end, preventing future subdomain namespace clashes from S3. By avoiding the need to patch the regex from the SDK side, AWS prevented triggering thousands of alerts and ensured backward compatibility. Creating such S3 bucket names is also no longer possible. Nevertheless, this solution applies only to AWS SDK users; if developers spin their own SigningCertURL validation algorithm (or fail to validate it at all), attackers can forge ahead…
Overall, this was a fruitful journey of code review and Reading the Funky Manual on the plumbing of today’s serverless cloud. Take a look at AWS’ excellent developer documentation for any service - you might just find something interesting.
Top comments (0)