GMO Flatt Security Blog

GMO Flatt Security株式会社の公式ブログです。プロダクト開発やプロダクトセキュリティに関する技術的な知見・トレンドを伝える記事を発信しています。

GMO Flatt Security株式会社の公式ブログです。
プロダクト開発やプロダクトセキュリティに関する技術的な知見・トレンドを伝える記事を発信しています。

AWS公式SDKにも存在した、署名付きURLにおけるパストラバーサル

はじめに

こんにちは。GMO Flatt Securityのセキュリティエンジニアの松井(@ryotaromosao)とチョン(Eui Chul Chung)です。 皆さんは、「署名付きURLにおけるパストラバーサル」の脆弱性をご存知でしょうか? Webアプリケーションで署名付きURLを実装する際、AWS公式のSDKを用いることが多いかと思います。過去にはその公式SDK自体にパストラバーサルの脆弱性が見つかった事例がありました。また一方で、公式SDK側では正しい対策がされているものの、アプリケーション開発者の実装ミスによってパストラバーサルが引き起こされてしまうケースも存在します。

本記事では、実際にAWS SDKで見つかった脆弱性の事例を交えながら、コードベースで署名付きURLにおけるパストラバーサルの脆弱性を深掘りしていきたいと思います。また、後半では、SDKを利用するアプリケーション開発者の実装ミスのパターンもご紹介します。署名付きURLを実装する開発者の方にとって、すぐ実務に活かせる内容となっていますので、ぜひご覧ください。

また、本ブログの内容はJAWS DAYS 2026で発表したものになります。スライドも公開していますので、併せてご参照ください。

免責事項

本稿の内容はセキュリティに関する知見を広く共有する目的で執筆されており、脆弱性の悪用などの攻撃行為を推奨するものではありません。許可なくプロダクトに攻撃を加えると犯罪になる可能性があります。当社が記載する情報を参照・模倣して行われた行為に関して当社は一切責任を負いません。

署名付きURLについて

署名付きURLとは、Amazon S3上のオブジェクトに対する一時的なアクセス権を付与したURLのことです。通常、S3上のオブジェクトを操作するには、必要な権限が付与されたIAMの認証情報、もしくは対象ロールをAssumeRoleして取得した一時的な認証情報を用いて、各種操作を行う必要があります。

しかし、S3上にあるオブジェクトを一時的に公開したい場合や、IAM認証情報を持っていない人に対して一時的にオブジェクトのダウンロード・アップロードを許可したい場合があります。そういった場合に利用されるのが署名付きURLです。 署名付きURLを利用すると、アプリケーションサーバーを経由せずにクライアントとS3間で直接オブジェクトの送受信ができるため、サーバーの負荷を大幅に削減できるというメリットがあり、多くのWebアプリケーションで利用されています。

なお、署名付きURLの重要な仕様として、URLの発行元(署名に用いたIAM権限)自身が、対象の操作に必要な権限を持っている場合に限りアクセスが許可される、という点が挙げられます。そのため、例えば一般ユーザーがmy-flatt-bucket内のオブジェクトに対するダウンロード用の署名付きURLを受け取ったとしても、実際にダウンロードが成功するのは、以下のように、URLを発行したアプリケーションサーバーが、my-flatt-bucketのオブジェクトのダウンロードに必要な権限(s3:GetObject)を持っている場合に限られます。

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Sid": "AllowDownloadFromMyFlattBucket",
            "Effect": "Allow",
            "Action": [
                "s3:GetObject"
            ],
            "Resource": [
                "arn:aws:s3:::my-flatt-bucket/*"
            ]
        }
    ]
}

署名付きURLの形式

S3で発行される署名付きURLには、以下の2つの形式が存在します。 1つ目はパス形式で、バケット名をURLのパス部分に配置する形式です。2つ目は仮想ホスト形式で、バケット名をサブドメインの一部として配置する形式です。ここでパス形式に関しては、AWS公式ブログで記載されていた通り、元々は「2020年9月30日以降に作成されたバケットに対するパス形式のサポート廃止」が予定されていました。しかし、バケット名にドット(.)が含まれる環境でのSSL/TLS証明書の検証課題など、ユーザーからのフィードバックを受け、現在この完全廃止は延期されています。

そのため、現在でも技術的にはパス形式のURLを生成、利用することは可能ですが、既にレガシーな立ち位置の機能となっていることに注意してください。

形式
パス形式 https://s3.${region-code}.amazonaws.com/${bucket-name}/${object-key}
仮想ホスト形式 https://${bucket-name}.s3.${region-code}.amazonaws.com/${object-key}

署名付きURLにおけるパストラバーサル

S3のフラット構造

ここで少しS3の仕様について触れておきます。S3は、OSのファイルシステムが持つようなディレクトリ構造を持たず、フラットな構造でデータを管理しています。

公式ドキュメントでも以下のように説明されており、マネジメントコンソール上で見えているフォルダのような表示は、あくまで人間が整理・把握しやすいようにスラッシュ(/)で区切って見せているに過ぎません。

In Amazon S3 general purpose buckets, objects are the primary resources, and objects are stored in buckets. Amazon S3 general purpose buckets have a flat structure instead of a hierarchy like you would see in a file system. However, for the sake of organizational simplicity, the Amazon S3 console supports the folder concept as a means of grouping objects.(Amazon S3 の汎用バケットでは、オブジェクトが主要なリソースであり、オブジェクトはバケットに格納されます。Amazon S3 汎用バケットはフラットな構造であり、ファイルシステムに見られるような階層はありません。ただし、構造を分かりやすくするため、Amazon S3 コンソールでは、オブジェクトのグループ化の方法としてフォルダの概念をサポートしています。)

一般的なパストラバーサル攻撃は、../ などの相対パスを使って親ディレクトリへ遡ることで発生します。しかし、S3のフラットな構造においては、../を入力してもS3側で親ディレクトリとして解決されることはなく、単なる「../という文字列が含まれたオブジェクト名」として扱われるに過ぎません。そのため、S3側の仕様により、OSレベルで知られているようなパストラバーサルは発生しないと考えられます。

署名付きURLにおけるパストラバーサル

では、署名付きURLにおけるパストラバーサルとはどのような脆弱性なのでしょうか。前述の通り、S3自体はフラットな構造を持ち、../を含むオブジェクトキーもそのまま文字列として扱います。しかし、署名付きURLを生成する過程において、SDKの内部処理やアプリケーションコード内でパスの正規化が行われると、../が解決されてしまい、本来意図していないオブジェクトに対する署名付きURLが発行される可能性があります。

具体例として、マルチテナントのWebアプリケーションにおいて、テナントごとにS3バケット内のプレフィックスでオブジェクトを管理し、ユーザー入力であるオブジェクトキーをもとにダウンロード用の署名付きURLを発行するケースを考えます。

func generatePresignedURL(tenantID, userInputFilename string) (string, error) {
   objectKey := fmt.Sprintf("tenants/%s/files/%s", tenantID, userInputFilename)
   // tenantId: "my-tenant"
  
   req, _ := s3Client.GetObjectRequest(&s3.GetObjectInput{
       Bucket: aws.String("my-flatt-bucket"),
       Key:    aws.String(objectKey),
   })

   return req.Presign(15 * time.Minute)
}

この実装では、userInputFilenameにユーザー入力が渡されます。正常系では例えば、userInputFilename = "report.pdf"を入力とすると、オブジェクトキーはtenants/my-tenant/files/report.pdfとなり、意図通りに自テナントのオブジェクトに対する署名付きURLが発行されます。

しかし、SDKの内部処理やアプリケーションコード内でパスの正規化が行われる場合、攻撃者がuserInputFilename= "../../other-tenant/files/secret.txt"を指定すると、以下のように意図していない/other-tenant/files配下のオブジェクトに対して署名付きURLが発行されてしまいます。

ステップ オブジェクトキーの状態
正規化前 tenants/my-tenant/files/../../other-tenant/files/secret.txt
正規化後 tenants/other-tenant/files/secret.txt

S3自体は../を解決しないため、正規化されなければtenants/my-tenant/files/../../other-tenant/files/secret.txtという文字列がそのままオブジェクトキーとして扱われ、該当するオブジェクトが存在しない限りアクセスは失敗します。しかし、正規化が行われるとtenants/other-tenant/files/secret.txtという実在し得るオブジェクトに対する有効な署名付きURLが発行されてしまいます。

このように、署名付きURLにおけるパストラバーサルは、S3自体の脆弱性ではなく、署名付きURLを生成するまでの過程でパスの正規化が行われることに起因します。

上記の例は、同一バケット内のプレフィックスでテナント分離をしていた例になりますが、これからご紹介するAWS SDKの事例では、パストラバーサルによってバケットの境界を超えた署名付きURLの発行が可能になったケースもご紹介します。

AWS SDKにおけるパストラバーサル

ここからは、実際にAWS SDKにおいてどのようにパストラバーサルが発生し得るのかを具体的にみていきます。本ブログではAWS SDK for Go (v1)AWS SDK for JavaScript(v3)を取り上げます。

Go(v1)におけるS3 署名付きURLのパストラバーサル

まずは、AWS SDK for Go(v1)です。Go(v1)では、署名付きURLを発行する際、SDKの内部ではいくつかのステップを経て最終的なURLが組み立てられます。この組み立ての過程で決定される、発行されるURLの形式(パス形式 または 仮想ホスト形式)によって、パストラバーサルの影響範囲が大きく変わってきます。

コードベースの詳細

内部のプロセスを順番に追っていきましょう。

1. Requestオブジェクトの生成

署名付きURLの生成は、まず対象オペレーションのRequestオブジェクトを生成するところから開始されます。例えばGetObjectの場合、以下のコードのGetObjectRequest()が呼び出されます。 ここでHTTPPath: "/{Bucket}/{Key+}"というテンプレートは、c.newRequest()の内部処理でエンドポイントURL(例: https://s3.us-east-1.amazonaws.com) のパス部分にそのまま結合されます。この段階ではまだ{Bucket}{Key+}に実際の値は代入されていません。

aws-sdk-go-main/service/s3/api.go 4976~4990行目(v1.55.8)

func (c *S3) GetObjectRequest(input *GetObjectInput) (req *request.Request, output *GetObjectOutput) {
   op := &request.Operation{
      Name:       opGetObject,
      HTTPMethod: "GET",
      HTTPPath:   "/{Bucket}/{Key+}",
   }

   if input == nil {
      input = &GetObjectInput{}
   }

   output = &GetObjectOutput{}
   req = c.newRequest(op, input, output)
   return
}

2. パス形式 / 仮想ホスト形式の判定

次に、Presign()内でSign()が呼ばれ、r.Build()内でURLの組み立てが行われます。

aws-sdk-go-main/aws/request/request.go 436~447行目

func (r *Request) Sign() error {
   r.Build()
   if r.Error != nil {
       debugLogReqError(r, "Build Request", notRetrying, r.Error)
       return r.Error
   }

   SanitizeHostForHeader(r.HTTPRequest)

   r.Handlers.Sign.Run(r)
   return r.Error
}

その中でまず、署名付きURLをパス形式にするか、仮想ホスト形式にするかの判定が行われます。これはendpointHandler()という処理によって行われ、以下の条件で決定されます。

条件 URL形式
S3ForcePathStyle == true パス形式
S3ForcePathStyle == falseかつバケット名がDNS非互換 パス形式
S3ForcePathStyle == falseかつバケット名がDNS互換 仮想ホスト形式

デフォルトではS3ForcePathStylefalseに設定されており、DNS非互換の場合はパス形式として署名付きURLが発行されます。

DNS非互換の要件としては、バケット名がIPアドレス形式(例:192.0.2.1)であったり、バケット名に..が含まれている、スキームがHTTPSかつバケット名に.を含む、などが挙げられます。特に3つ目の要件について、SDKのGo(v1)ではデフォルトでHTTPSとして署名付きURLは発行されるので、バケット名にドットが含まれているとパス形式として署名付きURLが発行されます。

aws-sdk-go-main/service/s3/endpoint.go 115~120行目

func endpointHandler(req *request.Request) {
   endpoint, ok := req.Params.(endpointARNGetter)
   if !ok || !endpoint.hasEndpointARN() {
      updateBucketEndpointFromParams(req)
      return
   }

aws-sdk-go-main/service/s3/host_style_bucket.go 36~49行目

func updateEndpointForS3Config(r *request.Request, bucketName string) {
    forceHostStyle := aws.BoolValue(r.Config.S3ForcePathStyle)
    accelerate := aws.BoolValue(r.Config.S3UseAccelerate)

    if accelerate && accelerateOpBlacklist.Continue(r) {
        if forceHostStyle {
            if r.Config.Logger != nil {
                r.Config.Logger.Log("ERROR: ...")
            }
        }
        updateEndpointForAccelerate(r, bucketName)
    } else if !forceHostStyle && r.Operation.Name != opGetBucketLocation {
        updateEndpointForHostStyle(r, bucketName)
    }
}

仮想ホスト形式が選択された場合、moveBucketToHost()によってバケット名がサブドメインに移動し、removeBucketFromPath()によりパスから /{Bucket}が削除されます。

aws-sdk-go-main/service/s3/host_style_bucket.go 133~137行目

func moveBucketToHost(u *url.URL, bucket string) {
   u.Host = bucket + "." + u.Host
   removeBucketFromPath(u)
}

3. パラメータの展開とpath.Clean()

最後にrest.Build()が実行されます。この処理で呼び出されるbuildURI()によって、実際のバケット名やオブジェクトキーがテンプレートに埋め込まれます。

aws-sdk-go-main/private/protocol/rest/build.go 201~216行目

func buildURI(u *url.URL, v reflect.Value, name string, tag reflect.StructTag) error {
   value, err := convertType(v, tag)
   // ...
   u.Path = strings.Replace(u.Path, "{"+name+"}", value, -1)
   u.Path = strings.Replace(u.Path, "{"+name+"+}", value, -1)

   u.RawPath = strings.Replace(u.RawPath, "{"+name+"}", EscapePath(value, true), -1)
   u.RawPath = strings.Replace(u.RawPath, "{"+name+"+}", EscapePath(value, false), -1)

   return nil
}

そしてパラメータ展開の直後にcleanPath()が呼ばれます。そのcleanPath()の内部で呼ばれているpath.Clean()はGoの標準ライブラリで、パスの正規化を行います。そのため、バケット名やオブジェクトキーに含まれる../が解決された状態で署名付きURLが発行されてしまいます。

aws-sdk-go-main/private/protocol/rest/build.go 137~139行目

   if !aws.BoolValue(r.Config.DisableRestProtocolURICleaning) {
      cleanPath(r.HTTPRequest.URL)
   }

aws-sdk-go-main/private/protocol/rest/build.go 247~258行目

func cleanPath(u *url.URL) {
   hasSlash := strings.HasSuffix(u.Path, "/")

   // clean up path, removing duplicate `/`
   u.Path = path.Clean(u.Path)
   u.RawPath = path.Clean(u.RawPath)

   if hasSlash && !strings.HasSuffix(u.Path, "/") {
      u.Path += "/"
      u.RawPath += "/"
   }
}

4. URLに対する署名

そして最後にSign()内のr.Handlers.Sign.Run(r)が呼ばれ、組み立てたURLに対して署名が行われます。

aws-sdk-go-main/aws/request/request.go 436~447行目

func (r *Request) Sign() error {
   r.Build()
   if r.Error != nil {
       debugLogReqError(r, "Build Request", notRetrying, r.Error)
       return r.Error
   }

   SanitizeHostForHeader(r.HTTPRequest)

   r.Handlers.Sign.Run(r)
   return r.Error
}

パストラバーサルが発生する具体例

ここまでのステップで解説した「URLの組み立て」と「path.Clean()による正規化」の仕様を踏まえ、具体的なペイロードによってどのようにパストラバーサルが引き起こされるのか見ていきましょう。

  • パス形式

パス形式では、オブジェクトキーにfoo/../barを指定すると、path.Clean()による正規化の結果、同一バケット内のbarというオブジェクトキーに対する署名付きURLが発行されてしまいます。この時、バケット内の任意のオブジェクトの操作ができるため、先述のような同一バケットのプレフィックスでテナント管理している場合に問題になります。

ステップ パスの状態
endpointHandler() 後 /{Bucket}/{Key+}
buildURI() 後
(Bucket=my-flatt-bucket, Key=foo/../bar)
/my-flatt-bucket/foo/../bar
path.Clean() 後 /my-flatt-bucket/bar

さらに特筆すべき点として、パストラバーサルによりバケット間を超えたオブジェクトの操作ができる可能性があります。例えば、オブジェクトキーに../other-flatt-bucket/secret.txtを指定した場合、正規化によりパスは以下のようになります。

ステップ パスの状態
endpointHandler() 後 /{Bucket}/{Key+}
buildURI() 後(Bucket=my-flatt-bucket, Key=../other-flatt-bucket/secret.txt) /my-flatt-bucket/../other-flatt-bucket/secret.txt
path.Clean() 後 /other-flatt-bucket/secret.txt

path.Clean()後にother-flatt-bucketsecret.txtに対する署名付きURLを発行できることがわかります。つまり、オブジェクトキーをユーザー入力とするWebアプリケーションがあった場合、攻撃者はオブジェクトキーに上記のようなペイロードを指定することで、他バケットのオブジェクトに対する署名付きURLを発行することが可能です。

ただし、この生成されたURLで実際にオブジェクトのダウンロード・アップロードができるかどうかは、URLの作成に使用したポリシーに依存します。 例えば、IAMポリシーが以下のように、複数のバケットを跨ぐような広範な権限が付与されていた場合、本来アクセスさせるべきでない他バケットのオブジェクトが漏洩されたり、他バケットに対するオブジェクトのアップロードができる可能性があります。

{
   "Version": "2012-10-17",
   "Statement": [
       {
           "Sid": "AllowDownloadAndUploadFromMultipleBuckets",
           "Effect": "Allow",
           "Action": [
               "s3:GetObject",
               "s3:PutObject"
           ],
           "Resource": [
               "arn:aws:s3:::my-flatt-bucket/*",
               "arn:aws:s3:::other-flatt-bucket/*"
           ]
       }
   ]
}
  • 仮想ホスト形式

バケット名がサブドメインとして扱われる仮想ホスト形式に関しては、ホスト名部分はpath.Clean()の影響を受けないため別バケットへの横断はできませんが、同一バケット内におけるオブジェクトキーのパストラバーサルは同様に発生します。

ステップ ホスト (Host) パス (Path)
endpointHandler() 後 my-flatt-bucket.s3.amazonaws.com /{Key+}
buildURI() 後
(Key=foo/../bar)
my-flatt-bucket.s3.amazonaws.com /foo/../bar
path.Clean() 後 my-flatt-bucket.s3.amazonaws.com /bar

AWSセキュリティチームとのやりとり

この脆弱性をAWSのセキュリティチームに報告しました。実際に返ってきたメールが以下になります。(メール内容公開の許可をAWSに取っています。)

We are aware of many customers who have built up an implicit dependency on this behavior of path normalization. For that reason, we have retained this behavior by default for AWS SDK for Go V1 to maintain backward compatibility for customers that have older or difficult to update solutions that depend on this behavior. (多くの顧客が、このパス正規化の動作に暗黙的に依存していることを弊社は認識しております。そのため、この動作に依存する古いソリューションや更新が困難なソリューションをご利用のお客様に対して後方互換性を維持できるよう、AWS SDK for Go V1 ではデフォルトでこの動作を維持しています)

要するに、デフォルトでパスの正規化が行われる挙動に依存している顧客がおり、後方互換性を維持するために今後もこの挙動を維持するとのことでした。

対策

前述の通り、意図しないパスの正規化がSDK内部のpath.Clean()によって行われることが原因です。AWS SDK for Go(v1)では、設定値であるDisableRestProtocolURICleaningが用意されているので、正規化が不要な場合はこの設定を有効にすることで、この正規化を無効にすることができます。 この設定により、ユーザー入力に../が含まれていたとしても正規化されずに単なる文字列として扱われるため、パストラバーサルを防ぐことが可能です。

aws-sdk-go-main/aws/config.go 513-516行目

// WithDisableRestProtocolURICleaning sets a config DisableRestProtocolURICleaning value
// returning a Config pointer for chaining.
func (c *Config) WithDisableRestProtocolURICleaning(t bool) *Config {
   c.DisableRestProtocolURICleaning = &t
   return c
}

まとめ

ここまで、AWS SDK for Go(v1)におけるパストラバーサルを見ていきました。現在、Go(v1)はArchiveとなりAWS SDK for Go(v2)への移行が推奨されており、新規開発で採用される機会は減っています。しかし、過去に構築されたシステムでは依然として稼働しているケースが多く存在します。攻撃者がオブジェクトキーを操作できる状況下で、特定の設定やバケット名の条件によってパス形式の署名付きURLが発行されると、意図しない他バケットの操作が可能になるというセキュリティリスクにつながります。

JavaScript(v3)におけるCloudFront 署名付きURLのパストラバーサル

@aws-sdk/cloudfront-signerパッケージ(AWS SDK for JavaScript v3)によるCloudFrontの署名付きURLの発行においても、JavaScriptのURLコンストラクタの意図しないパス正規化によりパストラバーサルの脆弱性が生じていました。

コードベースの詳細

内部処理を順に追っていきます。

1. baseUrlの決定

署名付きURL生成は、まずbaseUrlを決定するところから始まります。getSignedUrl()に引数としてurlが与えられた場合はurlがそのままbaseUrlに代入され、policyが与えられた場合は該当引数からリソースURLを抽出してbaseUrlに設定します。

aws-sdk-js-v3/packages/cloudfront-signer/src/sign.ts 113~124行目(修正前)

let baseUrl: string | undefined;
if (url) {
 baseUrl = url;
} else if (policy) {
 const resources = getPolicyResources(policy!);
 if (!resources[0]) {
   throw new Error(
     "@aws-sdk/cloudfront-signer: No URL provided and unable to determine URL from first policy statement resource."
   );
 }
 baseUrl = resources[0].replace("*://", "https://");
}

2. URLの正規化とパラメータの展開

次に、new URL(baseUrl!)URLオブジェクトが生成され、生成されたオブジェクトに署名パラメータが追加されます。

aws-sdk-js-v3/packages/cloudfront-signer/src/sign.ts 126~131行目(修正前)

const newURL = new URL(baseUrl!);
newURL.search = Array.from(newURL.searchParams.entries())
 .concat(Object.entries(cloudfrontSignBuilder.createCloudfrontAttribute()))
 .filter(([, value]) => value !== undefined)
 .map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`)
 .join("&");

このフローの中で、URLコンストラクタによる自動的なパスの正規化が実行されるため、パスに含まれる../が相対パスとして解釈され、親ディレクトリへの移動として解決(正規化)されてしまいます。

パストラバーサルが発生する具体例

URLのパスとしてfoo/../barを指定すると、URLコンストラクタによる正規化の結果、barというパスへの署名付きURLが生成されます。

ステップ URLの状態
getSignedUrl 呼び出し時
(url=https://flatt-distribution.cloudfront.net/foo/../bar
https://flatt-distribution.cloudfront.net/foo/../bar
new URL() 後 https://flatt-distribution.cloudfront.net/bar

一見すると、CloudFrontディストリビューションの「URL」に対して署名を行う時、URLを正規化するのは自然な仕様のようにも見えます。しかし、CloudFrontは本来S3と組み合わせて使われるケースも想定されており、その時、URLパスの部分はS3オブジェクトキーと解釈される構成になっています。一方、前述の通り、S3はフラット構造のデータモデルを採用しているため、../のような文字列を含むオブジェクトキーの命名も許可されています。

このリソースパスの解釈ブレにより、攻撃でない正常なCloudFront利用においても意図しないリソースへのアクセスを許可してしまう可能性があります。例として一般的なケースではないものの、相対パスを含む有効なS3オブジェクトに対する署名が、URLの正規化により親フォルダに存在する別のオブジェクトに対する署名となってしまうケースなどが考えられます。

AWSセキュリティチームとのやりとり

AWSのセキュリティチームへ本件を報告した結果、以下の回答が返ってきました。

I would like to inform you that our CNA team has evaluated your reported issue for a CVE/GHSA assignment and determined that this does not qualify for a CVE under our program [1] as this issue requires overly permissive S3 bucket policies to be applied. The configuration of these policies fall under the customer side of the AWS Shared Responsibility Model [2]. (CVE/GHSA割り当てのために報告された問題を評価した結果、この問題はCVEの対象外であると判断しました。この問題はS3バケットポリシーとして過剰な権限が適用されている必要があり、これらのポリシーの設定は AWS Shared Responsibility Model における顧客側の責任範囲に該当します。)

開発者側の責任領域に属する事項であることから、正式な脆弱性としての認定には至らなかったわけです。とはいえ、報告内容は受け入れられ、@aws-sdk/cloudfront-signerv3.858.0での正規化挙動の削除へと繋がりました。

対策

@aws-sdk/cloudfront-signerv3.858.0において、URLコンストラクタの利用を廃止し、文字列操作でURL構築を実現するよう実装が改められました。

aws-sdk-js-v3/packages/cloudfront-signer/src/sign.ts 139~146行目(修正後)

const startFlag = baseUrl!.includes("?") ? "&" : "?";
const params = Object.entries(cloudfrontSignBuilder.createCloudfrontAttribute())
 .filter(([, value]) => value !== undefined)
 .map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(value)}`)
 .join("&");
const urlString = baseUrl + startFlag + params;

return getResource(urlString);

そのため、v3.858.0以前の@aws-sdk/cloudfront-signerを利用している際、修正以降のバージョンへとパッケージをアップデートすることでパストラバーサルの発生を回避できます。

まとめ

以上、AWS SDK for JavaScript v3の@aws-sdk/cloudfront-signerで発生していたパストラバーサルについて解説しました。本件の根本原因は、URLベースのアクセスを前提とするCloudFrontと、フラットなデータ構造を持つS3という、異なるデータモデルを採用したサービス間の仕様差にあると言えます。こうしたサービス間の仕様不整合に起因する脆弱性は、他のAWSサービス連携においても潜在している可能性があるため、今後の調査において一層注視する価値があると考えられます。

アプリケーション開発者が誤って正規化してしまうパターン

ここまで、AWS SDK内部でパスの正規化が行われてしまう事例を見てきました。ここで、SDK側で適切な実装がされていたとしても、SDKを呼び出す開発者自身が、アプリケーションのコード内で誤って正規化処理を行ってしまうケースがあります。ここでは、その代表的な3つのパターンを紹介します。

明示的に正規化をするパターン

1つ目は、開発者が明示的にパスの正規化を行う関数を呼び出してしまうケースです。これは先ほどのAWS SDK Go(v1)のpath.Clean()と同様のパターンです。S3のフラットな構造においては、以下のような関数で正規化をしてしまうと、本来意図していないオブジェクトに対しての署名付きURLが作成され、結果としてパストラバーサルが起こってしまいます。

  • path.normalize() (JavaScript)
  • os.path.normpath() (Python)
  • java.nio.file.Path.normalize() (Java)

path.normalize()におけるサンプルコード

const path = require("path");
const { S3Client, GetObjectCommand } = require("@aws-sdk/client-s3");
const { getSignedUrl } = require("@aws-sdk/s3-request-presigner");

const s3Client = new S3Client({ region: "ap-northeast-1" });

async function generatePresignedUrlWithNormalize(tenantId, userInputPath) {
 // tenantId: "my-tenant"
 // userInputPath(攻撃者の入力): "../../other-tenant/files/secret.txt"

 const rawPath = `tenants/${tenantId}/files/${userInputPath}`;

 // 正規化前: "tenants/my-tenant/files/../../other-tenant/files/secret.txt"
 // 正規化後: "tenants/other-tenant/files/secret.txt"
 const objectKey = path.normalize(rawPath);

 const command = new GetObjectCommand({
   Bucket: process.env.BUCKET_NAME,
   Key: objectKey,
});

return await getSignedUrl(s3Client, command, { expiresIn: 3600 });
}

パスの結合時に正規化してしまうパターン

2つ目は、ディレクトリとファイルを結合の際に、パス結合を行う関数を利用するケースです。これらの関数は、冗長なパス表記(//./など)を解決するために、実行時に内部でパスの正規化が行われる仕様になっています。そのため、ユーザー入力に../が含まれていた場合、パスが正規化されてしまいます。

  • path.join() (JavaScript)
  • path.Join(),filepath.Join() (Go)

path.join()におけるサンプルコード

const path = require("path");
const { S3Client, GetObjectCommand } = require("@aws-sdk/client-s3");
const { getSignedUrl } = require("@aws-sdk/s3-request-presigner");

const s3Client = new S3Client({ region: "ap-northeast-1" });

async function generatePresignedUrlWithPathJoin(tenantId, userInputFilename) {
 // tenantId: "my-tenant"
 // userInputFilename(攻撃者の入力): "../../other-tenant/files/secret.txt"

 // 正規化前:"tenants/my-tenant/files/../../other-tenant/files/secret.txt"
 // 正規化後:"tenants/other-tenant/files/secret.txt"
const objectKey = path.join("tenants", tenantId, "files", userInputFilename);

 const command = new GetObjectCommand({
   Bucket: process.env.BUCKET_NAME,
   Key: objectKey,
 });

 return await getSignedUrl(s3Client, command, { expiresIn: 3600 });
}

URL構築時に正規化してしまうパターン

3つ目は、ベースとなるURLにユーザー入力のパスを付与してURLオブジェクトを生成する際、内部的に../などの相対パスが解釈され、正規化が行われてしまうケースです。

特に、CloudFrontの署名付きURLを発行する際には、署名対象として完全なURL文字列を渡す必要があります。そのため、開発者がURLを組み立てる過程で以下のような関数を使用してしまうと、内部的な正規化により意図しないオブジェクトキーに対する署名付きURLが発行される可能性があります。

  • new URL() (JavaScript)
  • java.net.URI.resolve() (Java)
  • url.URL.ResolveReference() (Go)

new URL()におけるサンプルコード

const { getSignedUrl } = require("@aws-sdk/cloudfront-signer");

function generateCloudFrontSignedUrl(userInputPath) {
  // userInputPath(攻撃者の入力): "../../other-tenant/files/secret.txt"

  const rawUrl = "https://d111111abcdef8.cloudfront.net/tenants/my-tenant/files/" + userInputPath;

  // 正規化前: "https://d111111abcdef8.cloudfront.net/tenants/my-tenant/files/../../other-tenant/files/secret.txt"
  // 正規化後: "https://d111111abcdef8.cloudfront.net/tenants/other-tenant/files/secret.txt"
  const url = new URL(rawUrl);

  return getSignedUrl({
    url: url.toString(),
    keyPairId: "dummyKeyPairId",
    privateKey: dummyPrivateKey,
    dateLessThan: new Date(Date.now() + 3600 * 1000).toISOString(),
  });
}

さいごに

本ブログでは、署名付きURLにおけるパストラバーサルの脆弱性を扱いました。AWS公式のSDKのGo(v1)・JavaScript(v3)にその脆弱性があった事例や、SDK側では対策されているものの、アプリケーション開発者の実装ミスによってパストラバーサルが引き起こされてしまう3つのパターンをご紹介しました。これから署名付きURLを利用する機能を実装される方、あるいは既に運用されている方が一度、パストラバーサルに想いを馳せて、適切な実装になっているか確認するきっかけになればと思います。

GMO Flatt Securityの開発組織のためのセキュリティサービス