Amazon SNSメッセージの電子署名検証の重要性

この記事は公開されてから1年以上経過しています。情報が古い可能性がありますので、ご注意ください。

よく訓練されたアップル信者、都元です。AWSにおける Publisher / Subscriber モデルの通知 (Notification) インフラサービスとして、Amazon Simple Notification Service (SNS) があることは皆さんご存知かと思います。

SNSに対してPublisherから送信されたメッセージは、Subscriberに配信されます。Subscriberのプロトコルにはいくつかの種類があります。要するに、どのような仕組みでメッセージを受け取るのか、です。最も分かりやすいのは「Email」で、これはメッセージをメールとして受信します。その他、Amazon SQSのキューがenqueueオペレーションとして受け取ったり、HTTP/HTTPSサーバがPOSTリクエストとして受け取るような仕組みも用意されています。また、下図のように、1つのトピックにつきPublisherやSubscriberが複数いても構いません。

2014-05-21_1751

メッセージの真正性

さてこの仕組みですが。Subscriberに届いたメッセージは、正しいものでしょうか。

2014-05-21_1752

つまり、悪意の第三者(以下、攻撃者)がSNSからのメッセージを装って、偽のメッセージを送っていたりはしませんか? という疑いです。HTTP/HTTPSのエンドポイントは公開状態にしておく必要があり、認証等を掛けることができません。Emailアドレスにももちろん、認証を掛けることはできません。

SNSメッセージの電子署名

そんな時のために、SNSのメッセージには電子署名がついています。具体的に、SNSからHTTP/HTTPSとしてPOSTされるメッセージの例を見てみましょう。

POST / HTTP/1.1
x-amz-sns-message-type: Notification
x-amz-sns-message-id: da41e39f-ea4d-435a-b922-c6aae3915ebe
x-amz-sns-topic-arn: arn:aws:sns:us-east-1:123456789012:MyTopic
x-amz-sns-subscription-arn: arn:aws:sns:us-east-1:123456789012:MyTopic:2bcfbf39-05c3-41de-beaa-fcfcc21c8f55
Content-Length: 761
Content-Type: text/plain; charset=UTF-8
Host: ec2-50-17-44-49.compute-1.amazonaws.com
Connection: Keep-Alive
User-Agent: Amazon Simple Notification Service Agent

{
  "Type" : "Notification",
  "MessageId" : "da41e39f-ea4d-435a-b922-c6aae3915ebe",
  "TopicArn" : "arn:aws:sns:us-east-1:123456789012:MyTopic",
  "Subject" : "test",
  "Message" : "test message",
  "Timestamp" : "2012-04-25T21:49:25.719Z",
  "SignatureVersion" : "1",
  "Signature" : "EXAMPLElDMXvB8r9R83tGoNn0ecwd5UjllzsvSvbItzfaMpN2nk5HVSw7XnOn/49IkxDKz8YrlH2qJXj2iZB0Zo2O71c4qQk1fMUDi3LGpij7RCW7AW9vYYsSqIKRnFS94ilu7NFhUzLiieYr4BKHpdTmdD6c0esKEYBpabxDSc=",
  "SigningCertURL" : "https://sns.us-east-1.amazonaws.com/SimpleNotificationService-f3ecfb7224c7233fe7bb5f59f96de52f.pem",
  "UnsubscribeURL" : "https://sns.us-east-1.amazonaws.com/?Action=Unsubscribe&SubscriptionArn=arn:aws:sns:us-east-1:123456789012:MyTopic:2bcfbf39-05c3-41de-beaa-fcfcc21c8f55"
}

このように、POSTのボディ部はJSONフォーマットとなっており、そのSignatureプロパティに電子署名がついています。また、この署名の公開鍵はSigningCertURLに示されるURLからDLできます。

公開鍵をDLし、メッセージの内容と署名の検証を行えば、メッセージの真正性が確信できる、という仕組みです。

ただし、一点注意しなければいけません。攻撃者が、自分でキーペアを用意し、自分のサーバに公開鍵を上げ、自分でしっかり署名したメッセージを送ってくる可能性です。

というわけで、署名の検証の前段として、公開鍵の真正性についても検証が必要です。これについては、公開鍵URLのプロトコルがHTTPSであり、DL通信に際して正規のCAによる証明書による通信が行われること公開鍵URLのホスト名が...amazonaws.comであることを確認します。少々ややこしいですね。

(以下、新たなリスクの指摘があったため追記。)

さらに、攻撃者が自分のSNSトピックを作って、そのトピックに攻撃対象HTTP/HTTPSエンドポイントをSubscribeさせる、という攻撃方法もありえます。Subscription時には、confirm処理が必要となりますが、confirmすべきか否かの判定が甘いと、正規の署名がついたメッセージを送り込まれることになります。

2014-05-22_1008

これを防ぐには、Subscriptionリクエスト(下記)メッセージの鍵URLと署名をきちんと確認するのはもちろん、同時にTopicArnの値も想定通りのものかどうか、検証する必要があります。

POST / HTTP/1.1
x-amz-sns-message-type: SubscriptionConfirmation
x-amz-sns-message-id: 165545c9-2a5c-472c-8df2-7ff2be2b3b1b
x-amz-sns-topic-arn: arn:aws:sns:us-east-1:123456789012:MyTopic
x-amz-sns-subscription-arn: arn:aws:sns:us-east-1:123456789012:MyTopic:2bcfbf39-05c3-41de-beaa-fcfcc21c8f55
Content-Length: 1336
Content-Type: text/plain; charset=UTF-8
Host: example.com
Connection: Keep-Alive
User-Agent: Amazon Simple Notification Service Agent

{
  "Type" : "SubscriptionConfirmation",
  "MessageId" : "165545c9-2a5c-472c-8df2-7ff2be2b3b1b",
  "Token" : "2336412f37fb687f5d51e6e241d09c805a5a57b30d712f794cc5f6a988666d92768dd60a747ba6f3beb71854e285d6ad02428b09ceece29417f1f02d609c582afbacc99c583a916b9981dd2728f4ae6fdb82efd087cc3b7849e05798d2d2785c03b0879594eeac82c01f235d0e717736",
  "TopicArn" : "arn:aws:sns:us-east-1:123456789012:MyTopic",
  "Message" : "You have chosen to subscribe to the topic arn:aws:sns:us-east-1:123456789012:MyTopic.\nTo confirm the subscription, visit the SubscribeURL included in this message.",
  "SubscribeURL" : "https://sns.us-east-1.amazonaws.com/?Action=ConfirmSubscription&TopicArn=arn:aws:sns:us-east-1:123456789012:MyTopic&Token=2336412f37fb687f5d51e6e241d09c805a5a57b30d712f794cc5f6a988666d92768dd60a747ba6f3beb71854e285d6ad02428b09ceece29417f1f02d609c582afbacc99c583a916b9981dd2728f4ae6fdb82efd087cc3b7849e05798d2d2785c03b0879594eeac82c01f235d0e717736",
  "Timestamp" : "2012-04-26T20:45:04.751Z",
  "SignatureVersion" : "1",
  "Signature" : "EXAMPLEpH+DcEwjAPg8O9mY8dReBSwksfg2S7WKQcikcNKWLQjwu6A4VbeS0QHVCkhRS7fUQvi2egU3N858fiTDN6bkkOxYDVrY0Ad8L10Hs3zH81mtnPk5uvvolIC1CXGu43obcgFxeL3khZl8IKvO61GWB6jI9b5+gLPoBc1Q=",
  "SigningCertURL" : "https://sns.us-east-1.amazonaws.com/SimpleNotificationService-f3ecfb7224c7233fe7bb5f59f96de52f.pem"
}

そしてTopicArnのチェックと署名の検証は、TypeがSubscriptionConfirmationの時だけではなくNotificationUnsubscribeConfirmationの時、何れの場合でも行う必要があります。もしNotificationの時のTopicArnのチェックを怠った場合、下記のような攻撃が考えられます。

攻撃者は、自前でトピックとSubscriberを用意し、SNSに署名をさせたメッセージを入手します。そのメッセージをそのまま攻撃対象のサーバにPOSTします。

2014-05-22_1016

このように、メッセージ受信時にはTopicArnの値も想定通りのものであるかを確認する必要があります。さらにガッチリと脇を固めるためには、Subscription confirm時に返されるSubscriptionArnをSubscriber側で管理し、Notificationとして配信されたメッセージについて、自分がSubscription confirmを行った事実があるかどうかを検証する、ということも考えられます。

(追記ここまで。下記検証用コードにもロジック追加してますが。)

検証用コード

というわけで、このようなSNSメッセージの真正性検証を行うためのコードをJavaで書いてみました。AWS Java SDKにはSignatureCheckerというクラスが提供されているので、対して難しいことではありませんでした。

プログラム引数(arg[0])に、JSONが入っている前提です。

import java.io.IOException;
import java.io.InputStream;
import java.net.URL;
import java.security.cert.CertificateException;
import java.security.cert.CertificateExpiredException;
import java.security.cert.CertificateFactory;
import java.security.cert.CertificateNotYetValidException;
import java.security.cert.X509Certificate;
import java.util.Map;
import java.util.TreeMap;
import java.util.regex.Pattern;

import com.amazonaws.services.sns.util.SignatureChecker;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

public class SNSSignatureVerifier {
  
  private static Logger logger = LoggerFactory.getLogger(SNSSignatureVerifier.class);
  
  private static final SignatureChecker SIGNATURE_CHECKER = new SignatureChecker();
  
  private static final Map<String, X509Certificate> SNS_CERT_MAP = new TreeMap<String, X509Certificate>();
  
  private static final Pattern HOSTNAME_PATTERN = Pattern.compile("^sns\\.[\\-\\w]+\\.amazonaws.com$");

  private static final String EXPECTED_TOPIC_ARN = "arn:aws:sns:us-east-1:123456789012:MyTopic";
  
  
  public static void main(String[] args) throws IOException {
    String messageJson = args[0];
    boolean verificationResult = verifySignature(messageJson);
    System.out.println(verificationResult);
  }
  
  private static boolean verifySignature(String body) throws JsonProcessingException, IOException {
    JsonNode jsonNode = new ObjectMapper().readTree(body);
    
    if(jsonNode.get("topicArn").textValue().equals(EXPECTED_TOPIC_ARN) == false) {
      return false;
    }
    
    try {
      X509Certificate x509Cert = getX509Cert(jsonNode.get("SigningCertURL").textValue());
      return SIGNATURE_CHECKER.verifyMessageSignature(body, x509Cert.getPublicKey());
    } catch (Exception e) {
      logger.error("signature verification failed", e);
    }
    return false;
  }

  private static X509Certificate getX509Cert(String signingCertUrl) throws IOException, CertificateException {
    X509Certificate cert;
    try {
      cert = SNS_CERT_MAP.get(signingCertUrl);
      if (cert != null) {
        cert.checkValidity();
        return cert;
      }
    } catch (CertificateExpiredException | CertificateNotYetValidException e) {
      logger.info("cert expired");
    }
    
    URL signingCertURL = new URL(signingCertUrl);
    if ("https".equals(signingCertURL.getProtocol()) == false) {
      throw new IllegalStateException("Illegal Protocol for SigningCertURL: " + signingCertURL.getProtocol());
    }
    if (HOSTNAME_PATTERN.matcher(signingCertURL.getHost()).matches() == false) {
      throw new IllegalStateException("Illegal Host for SigningCertHost: " + signingCertURL.getHost());
    }
    
    try (InputStream inputStream = signingCertURL.openStream()) {
      cert = (X509Certificate) CertificateFactory.getInstance("X.509").generateCertificate(inputStream);
      SNS_CERT_MAP.put(signingCertUrl, cert);
      return cert;
    }
  }
}

verificationResulttrueであれば署名確認OK、falseであれば何かがおかしい、ということです。

まとめ

各エンドポイントにて受け取ったSNSメッセージは、実は悪意の第三者からのメッセージである可能性がありますので、正しくそのメッセージの真正性を検証しましょう。

ちなみにSNSのsubscription protocolを「Email」にした場合は、この署名がつきませんので悪意の第三者からのメッセージである可能性は否定できません。この場合「Email-JSON」とすれば上記のJSON形式でメール配信が行われますので、同様の検証が可能です。

参考文献

他にExample Code for an Amazon SNS Endpoint Java Servletというドキュメントもありましたが、SignatureCheckerを使わない、プリミティブな実装が紹介されていました。また、注意書きに「プロダクションでは、中間者攻撃(man-in-the-middle attacks)に備えたverifyも行うべき」との記述があるだけで、実際のチェックは実装されていないため、このままの仕組みでプロダクション利用するのは避けるべきだと思いました。