S3のメタデータを用いた攻撃
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を書くはずが結局年末になってしまったので、攻撃者のアプローチやアプリ実装時の注意点を中心に書き残しておきたいと思います。
攻撃の仕組みと事例
S3はPresigned URLやPOST Policyを使用することで、バックエンドを介さずに直接S3にオブジェクトをアップロードすることができます。バックエンドに負荷がかからない等の利点もあり、直接アップロードを使用するケースは少なくないと思いますが、次のような場合にXSSを引き起こす可能性があります。
- Presigned URLを使用しているが
Content-Type
ヘッダが署名の対象でない場合 - Presigned URL生成時の
Content-Type
のバリデーションが不適切な場合 - POST Policyのポリシードキュメントが不適切な場合
ここからはCTFで提供されたソースコードを例に、各ケースの問題点を解説したいと思います。
Content-Type
ヘッダが署名の対象でない場合
Presigned URLを使用しているがフロントエンドはユーザがアップロードするファイルを選択して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-Type
がimage/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やトークン)を奪取するといったことが可能となります。
有効な対策は、次のようにPresigned URLの生成時に明示的に必要なヘッダを含めることです。これによりPUTリクエストのContent-Type
の改竄を防止することができます。
const url = await getSignedUrl(s3, command, {
expiresIn: 60 * 60 * 24,
signableHeaders: new Set(['content-type', 'content-length']),
});
バックエンドのソースコード(全体)
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;
Content-Type
のバリデーションが不適切な場合
Presigned URL生成時の上記は署名の対象でない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-Type
がimage
という文字列で始まるというポリシーにマッチするため成功し、アップロードされたS3オブジェクトのContent-Type
メタデータの値はimage
になります。
このS3オブジェクトが要求された場合のレスポンスに含まれるContent-Type
ヘッダの値もimage
となりますが、不完全なMIME Typeを受信したブラウザはMIME Sniffingによって適切なMIME Typeを推測しようとします。このオブジェクトはHTMLファイルのためブラウザがtext/html
として扱い、結果的にXSSを引き起こす可能性があります。
この攻撃手法は、S3のContent-Type
メタデータの値を直接text/html
にするのではなく、不完全なMIME Typeに対するブラウザの挙動を利用してtext/html
として扱わせるものです。今回の例では、ポリシードキュメントはサブタイプを含めずに次のように指定するのが望ましいようです。
['starts-with', '$Content-Type', 'image/']
バックエンドのソースコード(全体)
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