💣

S3のメタデータを用いた攻撃

2024/12/30に公開

TL;DR

S3にファイルを直接アップロードする際に、

  • Presigned URLを使用する場合は、URL生成時のバリデーションは完全一致でやる&適切なヘッダを署名に含める
  • Post Policyを使用する場合は、ポリシーの抜け漏れに注意する

はじめに

今年特に感心したもののひとつが、S3等のオブジェクトストレージのメタデータの取り扱いに関する仕様をついた攻撃手法でした。S3には複数のメタデータがありますが、上記記事を書かれているAzaraさんによると特に注意すべきメタデータは次の4つのようです。

# Metadata About
1 Content-Type HTTPレスポンスのContent-Typeヘッダの値に使用される
2 Content-Disposition HTTPレスポンスのContent-Dispositionヘッダの値に使用される
3 x-amz-storage-class オブジェクトの保存に使用するストレージクラスを指定
4 x-amz-website-redirect-location 静的ウェブサイトのホスティングが有効なバケットで、オブジェクトに対するリクエストのリダイレクト先を指定

例えば、Content-Typeメタデータの値を細工したオブジェクトを取得させることでXSSを発生させたり、Content-Dispositionメタデータを細工してRFD (Reflected File Download) を引き起こしたり、 x-amz-storage-classメタデータを操作して意図せぬストレージクラスを使用させEDoS (Economical Deninal of Sustainability)を発生させたり、といった攻撃が成立する可能性があります。

中でもContent-Typeを悪用したXSSは、S3の仕様や使用方法だけでなく、ブラウザの挙動にも注意を払う必要があり、アプリ開発者は攻撃の原理と対処を理解しておく必要があります。

9/21にAzaraさんとSecurity-JAWSのコラボで、この問題にフォーカスしたCTFイベント「とある海豹とSecurity-JAWS #01」が開催されました。すぐにWriteUpを書くはずが結局年末になってしまったので、攻撃者のアプローチやアプリ実装時の注意点を中心に書き残しておきたいと思います。

https://s-jaws.connpass.com/event/329267/

攻撃の仕組みと事例

S3はPresigned URLPOST Policyを使用することで、バックエンドを介さずに直接S3にオブジェクトをアップロードすることができます。バックエンドに負荷がかからない等の利点もあり、直接アップロードを使用するケースは少なくないと思いますが、次のような場合にXSSを引き起こす可能性があります。

  • Presigned URLを使用しているがContent-Typeヘッダが署名の対象でない場合
  • Presigned URL生成時のContent-Typeのバリデーションが不適切な場合
  • POST Policyのポリシードキュメントが不適切な場合

ここからはCTFで提供されたソースコードを例に、各ケースの問題点を解説したいと思います。

Presigned URLを使用しているがContent-Typeヘッダが署名の対象でない場合

フロントエンドはユーザがアップロードするファイルを選択してSubmitすると、バックエンドからPresigned URLを取得し、そのURLを用いてユーザのブラウザから直接S3に選択したファイルをアップロードする、よくある作りです。

ファイルアップロードフォーム
<form name="fileForm">
    <input
    type="file"
    name="file"
    id="file"
    />
    <input
    type="submit"
    value="Submit"
    />
</form>
ファイルアップロードフォームのスクリプト
document.fileForm.addEventListener('submit', async (e) => {
    e.preventDefault();
    const file = document.fileForm.file.files[0];
    const response = await fetch('/api/upload', {
        method: 'POST',
        headers: {
            'Content-Type': 'application/json',
        },
        body: JSON.stringify({
            contentType: file.type,
            length: file.size,
        }),
    });

    if (!response.ok) {
        document.getElementById('result').innerText = 'Failed to get presigned URL';
        return false;
    }
    const responseJson = await response.json();
    const uploadUrl = responseJson.url;
    const uploadResponse = await fetch(uploadUrl, {
        method: 'PUT',
        headers: {
            'Content-Type': file.type,
        },
        body: file,
    });

    document.getElementById('result').innerHTML = `<img src="/upload/${responseJson.filename}" />`;
});

バックエンドはPresigned URLのリクエストのContent-Typeimage/png, image/jpeg, image/jpgのいずれかであるかチェックしており、いずれでもない場合はエラーを返すようになっています。

const allow = ['image/png', 'image/jpeg', 'image/gif'];
if (!allow.includes(request.body.contentType)) {
    return reply.code(400).send({ error: 'Invalid file type' });
}

一見問題なさそうに見えるのですが、次のPresigned URLの生成処理には問題があり、取得したPresigned URLにファイルを送信する際に任意のContent-Typeを指定することができます。これはAWS SDK for JavaScript v3@aws-sdk/s3-request-presignerは、signableHeadersとして明示的に指定しない限り、x-amz-*以外のヘッダを署名に含めないという仕様によるものです。

import { S3Client, PutObjectCommand, HeadObjectCommand } from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';
...<省略>...
const s3 = new S3Client({});
const command = new PutObjectCommand({
    Bucket: process.env.BUCKET_NAME,
    Key: `upload/${filename}`,
    ContentLength: request.body.length,
    ContentType: request.body.contentType,
});
const url = await getSignedUrl(s3, command, {
    expiresIn: 60 * 60 * 24,
});

Content-Typeヘッダは署名されていないので、Burp SuiteのInterceptなどを用いて、Presigned URLを取得するときはimage/pngなどに書き換えてチェックを通過し、その後取得したPresigned URLへのPUTリクエストではtext/htmlに書き換えてやるだけで、HTMLファイルのアップロードに成功します。

S3はアップロードされたオブジェクトのメタデータにセットされたtext/htmlを、オブジェクトのレスポンスのContent-Typeヘッダとして返します。そのため、例えば経費精算のようなアプリケーションにおいて、悪意のあるユーザがこのような方法で領収書に見せかけた攻撃スクリプトをアップロードし、承認者に開かせることでXSSを発生させ、承認者の機微な情報(Cookieやトークン)を奪取するといったことが可能となります。

Content-Type header tampering

有効な対策は、次のようにPresigned URLの生成時に明示的に必要なヘッダを含めることです。これによりPUTリクエストのContent-Typeの改竄を防止することができます。

const url = await getSignedUrl(s3, command, {
    expiresIn: 60 * 60 * 24,
    signableHeaders: new Set(['content-type', 'content-length']),
});
バックエンドのソースコード(全体)
app.ts
import fastify from 'fastify';
import { fastifyStatic } from '@fastify/static';

import path from 'path';
import { v4 as uuidv4 } from 'uuid';
import { S3Client, PutObjectCommand, HeadObjectCommand } from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';

import { SQSClient, SendMessageCommand } from '@aws-sdk/client-sqs';

const server = fastify();

server.register(fastifyStatic, {
    root: path.join('/app', 'public'),
});

const port = 3000;
const stage = process.env.STAGE || 'dev';

server.get('/', async (request, reply) => {
    return reply.sendFile('index.html');
});

server.post<{
    Body: {
        contentType: string;
        length: number;
    };
}>('/api/upload', async (request, reply) => {
    if (!request.body.contentType || !request.body.length) {
        return reply.code(400).send({ error: 'No file uploaded' });
    }

    if (request.body.length > 1024 * 1024 * 100) {
        return reply.code(400).send({ error: 'File too large' });
    }

    const allow = ['image/png', 'image/jpeg', 'image/gif'];
    if (!allow.includes(request.body.contentType)) {
        return reply.code(400).send({ error: 'Invalid file type' });
    }

    const filename = uuidv4();
    const s3 = new S3Client({});
    const command = new PutObjectCommand({
        Bucket: process.env.BUCKET_NAME,
        Key: `upload/${filename}`,
        ContentLength: request.body.length,
        ContentType: request.body.contentType,
    });

    const url = await getSignedUrl(s3, command, {
        expiresIn: 60 * 60 * 24,
    });
    return reply.header('content-type', 'application/json').send({
        url,
        filename,
    });
});

server.post<{ Body: { url: string } }>('/api/report', async (request, reply) => {
    const data = request.body;
    if (!data) {
        return reply.code(400).send({ error: 'No url provided' });
    }

    const sqs = new SQSClient({});
    const command = new SendMessageCommand({
        QueueUrl: process.env.QUEUE_URL,
        MessageBody: data.url,
    });

    await sqs.send(command);

    return reply.send('Crawling request has been sent');
});

server.get<{ Params: { filename: string } }>('/api/report/:filename', async (request, reply) => {
    console.log(`Params: ${request.params}`);
    const s3 = new S3Client({});
    const command = new HeadObjectCommand({
        Bucket: process.env.DELIVERY_BUCKET_NAME,
        Key: `delivery/${request.params.filename}`,
    });
    try {
        console.log(`command: ${command}`);
        const response = await s3.send(command);
        console.log(`response: ${JSON.stringify(response)}`);
        reply.send({ message: 'ok' });
        return reply;
    } catch (e) {
        console.error(e);
        return reply.code(500).send({ error: 'File not found' });
    }
});

if (stage === 'dev') {
    server.listen({ port: port, host: '0.0.0.0' }, (err, address) => {
        if (err) {
            console.error(err);
            server.log.error(err);
            process.exit(1);
        }
        server.log.info(`server listening on ${address}`);
    });
}

export default server;

Presigned URL生成時のContent-Typeのバリデーションが不適切な場合

上記は署名の対象でないContent-Typeヘッダを改竄をするという攻撃手法でしたが、必要なヘッダを署名の対象に含めるだけでなく、署名する情報自体の検証にも注意する必要があります。Presigned URLのリクエストに含まれるContent-Typeヘッダの検証ロジックが不適切な場合、細工した文字列を指定することで、XSSを引き起こせる可能性があります。

例えば次のような末尾文字列の一致による検証ロジックは、Content-Typeヘッダにtext/html; image/pngのような値を指定すると通過可能で、ブラウザはMIME Typeがtext/htmlであるとして処理します。

const contentTypeValidator = (contentType: string) => {
    if (contentType.endsWith('image/png')) return true;
    if (contentType.endsWith('image/jpeg')) return true;
    if (contentType.endsWith('image/jpg')) return true;
    return false;
};
if (!contentTypeValidator(request.body.contentType)) {
    return reply.code(400).send({ error: 'Invalid file type' });
}

また、次のような先頭文字列と末尾文字列を組み合わせた検証ロジックも一見問題なさそうですが、Content-Typeヘッダにimage/png, text/html; image/pngのような値を指定すると通過可能で、ブラウザはMIME Typeがtext/htmlであるとして処理します。

const allowContentTypes = ['image/png', 'image/jpeg', 'image/jpg'];
const isAllowContentType = allowContentTypes.filter((contentType) => request.body.contentType.startsWith(contentType) && request.body.contentType.endsWith(contentType));
if (isAllowContentType.length === 0) {
    return reply.code(400).send({ error: 'Invalid file type' });
}

正規表現と;を含むか否かを組み合わせた次のような検証ロジックも、Content-Typeヘッダにtext/html image/pngのような値を指定すると通過可能で、ブラウザはMIME Typeがtext/htmlであるとして処理します。

if (request.body.contentType.includes(';')) {
    return reply.code(400).send({ error: 'No file type (only type/subtype)' });
}
const allow = new RegExp('image/(jpg|jpeg|png|gif)$');
if (!allow.test(request.body.contentType)) {
    return reply.code(400).send({ error: 'Invalid file type' });
}

これらはContent-Typeに複数の値が指定された際のブラウザの挙動を利用した攻撃手法ですが、RFCとWHATWGの定義が完全に一致しておらずブラウザによって差異もあるようなので取り扱いが非常に難しそうです。よほど特別な事情がない限り、部分一致ではなく完全一致で検証を行うのが最善策だと思います。

POST Policyのポリシードキュメントが不適切な場合

フロントエンドはPresigned URLの場合とよく似ており、ユーザがアップロードするファイルを選択してSubmitすると、バックエンドからポリシードキュメントや署名を取得し、ユーザのブラウザから直接S3に選択したファイルをアップロードする作りです。

ファイルアップロードフォーム
<form name="fileForm">
    <input
    type="file"
    name="file"
    id="file"
    />
    <input
    type="submit"
    value="Submit"
    />
</form>
ファイルアップロードフォームのスクリプト
document.fileForm.addEventListener('submit', async (e) => {
    e.preventDefault();
    const file = document.fileForm.file.files[0];
    const allow = ['image/jpeg', 'image/png', 'image/gif'];
    if (!allow.includes(file.type)) {
        document.getElementById('result').innerText = 'Invalid file type';
        return;
    }
    const response = await fetch('/api/upload', {
        method: 'POST',
        headers: {
            'Content-Type': 'application/json',
        },
        body: JSON.stringify({
            contentType: file.type,
            length: file.size,
        }),
    });

    if (!response.ok) {
        document.getElementById('result').innerText = 'Failed to get presigned URL'; //メッセージは多分修正忘れ
        throw new Error('Failed to get presigned URL');
    }
    const responseJson = await response.json();
    const fields = responseJson.fields;
    const uploadUrl = responseJson.url;

    const formData = new FormData();
    Object.keys(fields).forEach((key) => {
        formData.append(key, fields[key]);
    });
    formData.append('file', file);
    const uploadResponse = await fetch(uploadUrl, {
        method: 'POST',
        body: formData,
    });

    if (!uploadResponse.ok) {
        document.getElementById('result').innerText = 'Failed to upload file';
        throw new Error('Failed to upload file');
    }

    document.getElementById('result').innerHTML = `<img src="/${fields.key}" />`;
});

Post Policyを使用する理由として、アップロード可能なファイルサイズやContent-Type等を制限したいといったものが多いと思います。Post Policyを用いたアップロードでは、ポリシードキュメントを指定することでアップロードするオブジェクトがポリシーに合致しているかS3にチェックさせることができます。

次のポリシードキュメントはContent-Typeヘッダの値がimageという文字列で始まることを
要求するもので、画像ファイルのみをアップロードできることを意図したものですが、XSSにつながる可能性があります。

import { S3Client, HeadObjectCommand } from '@aws-sdk/client-s3';
import { createPresignedPost } from '@aws-sdk/s3-presigned-post';
...<省略>...
const s3 = new S3Client({});
const { url, fields } = await createPresignedPost(s3, {
    Bucket: process.env.BUCKET_NAME!,
    Key: `upload/${filename}`,
    Conditions: [
        ['content-length-range', 0, 1024 * 1024 * 100],
        ['starts-with', '$Content-Type', 'image'],
    ],
    Fields: {
        'Content-Type': request.body.contentType,
    },
    Expires: 600,
});
return reply.header('content-type', 'application/json').send({
    url,
    fields,
});

例えば、XSSを引き起こすスクリプトを含んだHTMLファイルをS3にアップロードする場合を考えてみましょう。この際、Burp SuiteのIntercept等を用いて、S3へのPOSTリクエストのContent-Typeヘッダをimageのような不完全なMIME Typeに書き換えます。このリクエストはContent-Typeimageという文字列で始まるというポリシーにマッチするため成功し、アップロードされたS3オブジェクトのContent-Typeメタデータの値はimageになります。

このS3オブジェクトが要求された場合のレスポンスに含まれるContent-Typeヘッダの値もimageとなりますが、不完全なMIME Typeを受信したブラウザはMIME Sniffingによって適切なMIME Typeを推測しようとします。このオブジェクトはHTMLファイルのためブラウザがtext/htmlとして扱い、結果的にXSSを引き起こす可能性があります。

Invalid Content-Type header

この攻撃手法は、S3のContent-Typeメタデータの値を直接text/htmlにするのではなく、不完全なMIME Typeに対するブラウザの挙動を利用してtext/htmlとして扱わせるものです。今回の例では、ポリシードキュメントはサブタイプを含めずに次のように指定するのが望ましいようです。

['starts-with', '$Content-Type', 'image/']
バックエンドのソースコード(全体)
app.ts
import fastify from 'fastify';
import { fastifyStatic } from '@fastify/static';

import path from 'path';
import { v4 as uuidv4 } from 'uuid';
import { S3Client, HeadObjectCommand } from '@aws-sdk/client-s3';
import { createPresignedPost } from '@aws-sdk/s3-presigned-post';

import { SQSClient, SendMessageCommand } from '@aws-sdk/client-sqs';

const server = fastify();

server.register(fastifyStatic, {
    root: path.join('/app', 'public'),
});

const port = 3000;
const stage = process.env.STAGE || 'dev';

server.get('/', async (request, reply) => {
    return reply.sendFile('index.html');
});

server.post<{
    Body: {
        contentType: string;
        length: number;
    };
}>('/api/upload', async (request, reply) => {
    if (!request.body.contentType || !request.body.length) {
        return reply.code(400).send({ error: 'No file uploaded' });
    }

    if (request.body.length > 1024 * 1024 * 100) {
        return reply.code(400).send({ error: 'File too large' });
    }

    const filename = uuidv4();
    const s3 = new S3Client({});
    const { url, fields } = await createPresignedPost(s3, {
        Bucket: process.env.BUCKET_NAME!,
        Key: `upload/${filename}`,
        Conditions: [
            ['content-length-range', 0, 1024 * 1024 * 100],
            ['starts-with', '$Content-Type', 'image'],
        ],
        Fields: {
            'Content-Type': request.body.contentType,
        },
        Expires: 600,
    });
    return reply.header('content-type', 'application/json').send({
        url,
        fields,
    });
});

server.post<{ Body: { url: string } }>('/api/report', async (request, reply) => {
    const data = request.body;
    if (!data) {
        return reply.code(400).send({ error: 'No url provided' });
    }

    const sqs = new SQSClient({});
    const command = new SendMessageCommand({
        QueueUrl: process.env.QUEUE_URL,
        MessageBody: data.url,
    });

    await sqs.send(command);

    return reply.send('Crawling request has been sent');
});

server.get<{ Params: { filename: string } }>('/api/report/:filename', async (request, reply) => {
    console.log(`Params: ${JSON.stringify(request.params)}`);
    const s3 = new S3Client({});
    const command = new HeadObjectCommand({
      Bucket: process.env.DELIVERY_BUCKET_NAME,
        Key: `delivery/${request.params.filename}`,
    });
    try {
        console.log(`command: ${JSON.stringify(command)}`);
        const response = await s3.send(command);
        console.log(`response: ${JSON.stringify(response)}`);
        reply.send({ message: 'ok' });
        return reply;
    } catch (e) {
        console.error(e);
        return reply.code(500).send({ error: 'File not found' });
    }
});

if (stage === 'dev') {
    server.listen({ port: port, host: '0.0.0.0' }, (err, address) => {
        if (err) {
            console.error(err);
            server.log.error(err);
            process.exit(1);
        }
        server.log.info(`server listening on ${address}`);
    });
}

export default server;

おわりに

実際の攻撃手法をもとに、S3に直接ファイルをアップロードする際に注意すべき点を整理してみました。サーバサイドアップロードであれば、WAFでXSSを引き起こすファイルのアップロードを未然にブロックできる可能性もありますが、S3への直接アップロードの場合はWAFによる保護は期待できません。署名生成の仕組みやブラウザの挙動をきちんと理解してアプリを実装する必要があります。開催から随分時間が経ってしまいましたが、改めて振り返ってもとても勉強になるCTFでした。AzaraさんとSecuirty-JAWSの皆さんに感謝!

Discussion