cd ~

2023 pbctf



I decided to run pbctf alone this time, as team ‘soon_haari solo play with beer’.

Ended up with 26th position. If I have solved Blocky 4, which is about a famous attack for ciphers with low rounds(and considered easier than ECC2 which I have solved), maybe I have ended 21st, or even 14th if I solved the harder version of it, but it’s all fun and games.

Being top 1 for an hour, and having a first blood felt nice, hehe.



Blocky - 0 (143 solves)

All this GF(3) idea for the new AES was very interesting.

Although none of the challenges use the vulnerabilities inside that I think.

task.py

#!/usr/bin/env python3
import hashlib
import os
import signal

from Cipher import BlockCipher
from GF import GF

def handler(_signum, _frame):
    print("Time out!")
    exit(0)


def get_random_block():
    block = b''
    while len(block) < 9:
        b = os.urandom(1)
        if b[0] < 243:
            block += b
    return block


def get_mac(pt):
    mac = hashlib.sha256(pt).digest()[:9]
    return bytes([x % 243 for x in mac])


def pad(pt):
    mac = get_mac(pt)
    v = 9 - len(pt) % 9
    return pt + bytes([v] * v) + mac


def unpad(pt):
    if len(pt) < 18 or len(pt) % 9 != 0:
        return
    pt, mac = pt[:-9], pt[-9:]
    if not (1 <= pt[-1] <= 9):
        return
    
    pt = pt[:-pt[-1]]
    if mac == get_mac(pt):
        return pt


def add(a, b):
    return bytes([(GF(x) + GF(y)).to_int() for x, y in zip(a, b)])


def sub(a, b):
    return bytes([(GF(x) - GF(y)).to_int() for x, y in zip(a, b)])


def main():
    signal.signal(signal.SIGALRM, handler)
    signal.alarm(60)
    key = get_random_block()
    cipher = BlockCipher(key, 20)

    while True:
        inp = input("> ")

        if inp == 'E':
            inp = input("Input (in hex): ")
            inp = bytes.fromhex(inp)
            assert len(inp) < 90
            assert all(b < 243 for b in inp)

            if inp == 'gimmeflag':
                print("Result: None")
                continue

            pt = pad(inp)
            iv = get_random_block()
            enc = iv

            for i in range(0, len(pt), 9):
                t = add(pt[i:i+9], iv)
                iv = cipher.encrypt(t)
                enc += iv
            
            print(f"Result: {enc.hex()}")
        elif inp == 'D':
            inp = input("Input (in hex): ")
            inp = bytes.fromhex(inp)
            assert len(inp) < 108
            assert all(b < 243 for b in inp)

            iv, ct = inp[:9], inp[9:]
            dec = b''

            for i in range(0, len(ct), 9):
                t = cipher.decrypt(ct[i:i+9])
                dec += sub(t, iv)
                iv = ct[i:i+9]

            pt = unpad(dec)
            if pt == b'gimmeflag':
                with open('flag', 'r') as f:
                    flag = f.read()
                    print(flag)
                exit(0)
            elif pt:
                print(f"Result: {pt.hex()}")
            else:
                print("Result: None")


if __name__ == "__main__":
    main()

It filters ‘gimmeflag’ in the encryption function, but it doesn’t filter additional blocks in the end.

So it worked when I just encrypted the padded message itself, cuz it is ‘gimmeflag{blahblahblah}. Not much of a hard challenge.

ex.py

from pwn import *
from Crypto.Util.number import *
import hashlib

def get_mac(pt):
    mac = hashlib.sha256(pt).digest()[:9]
    return bytes([x % 243 for x in mac])


def pad(pt):
    mac = get_mac(pt)
    v = 9 - len(pt) % 9
    return pt + bytes([v] * v) + mac


def unpad(pt):
    if len(pt) < 18 or len(pt) % 9 != 0:
        return
    pt, mac = pt[:-9], pt[-9:]
    if not (1 <= pt[-1] <= 9):
        return
    
    pt = pt[:-pt[-1]]
    if mac == get_mac(pt):
        return pt

goal = pad(b"gimmeflag")
assert unpad(goal) == b"gimmeflag"

# print(len(goal))


io = remote("blocky-0.chal.perfect.blue", 1337)
io.sendline("E")

io.sendline(bytes.hex(goal))
io.recvuntil("Result: ")
enc = bytes.fromhex(io.recvline().decode()[:-1])

enc = enc[:36]

io.sendline("D")
io.sendline(bytes.hex(enc))


io.interactive()

Also, I heard that just encrypting ‘gimmeflag’ works because b”gimmeflag” isn’t same to “gimmeflag”….



My ECC Service (32 solves)

challenge.py

from Crypto.Util.number import inverse
from hashlib import sha256
import os
import signal

class NonceGenerator:
    def __init__(self):
        self.state = os.urandom(10)
        self.db = {}
    
    def gen(self):
        self.state = sha256(self.state + b'wow').digest()[:10]
        key = sha256(self.state).digest()[:8]
        self.db[key] = self.state

        return int.from_bytes(self.state, 'big'), key

    def get(self, key: str):
        if key not in self.db:
            print("Wrong key :(")
            exit(0)

        return int.from_bytes(self.db[key], 'big')


class ECPoint:
    def __init__(self, point, mod):
        self.x = point[0]
        self.y = point[1]
        self.mod = mod

    def inf(self):
        return ECPoint((0, 0), self.mod)

    def _is_inf(self):
        return self.x == 0 and self.y == 0

    def __eq__(self, other):
        assert self.mod == other.mod
        return self.x == other.x and self.y == other.y

    def __add__(self, other):
        assert self.mod == other.mod
        P, Q = self, other
        if P._is_inf() and Q._is_inf():
            return self.inf()
        elif P._is_inf():
            return Q
        elif Q._is_inf():
            return P

        if P == Q:
            lam = (3 * P.x**2 - 3) * inverse(2 * P.y, self.mod) % self.mod
        elif P.x == Q.x:
            return self.inf()
        else:
            lam = (Q.y - P.y) * inverse(Q.x - P.x, self.mod) % self.mod

        x = (lam**2 - P.x - Q.x) % self.mod
        y = (lam * (P.x - x) - P.y) % self.mod

        return ECPoint((x, y), self.mod)

    def __rmul__(self, other: int):
        base, ret = self, self.inf()
        while other > 0:
            if other & 1:
                ret = ret + base
            other >>= 1
            base = base + base
        return ret


class MyECCService:
    BASE_POINT = (2, 3)
    MODS = [
        942340315817634793955564145941,
        743407728032531787171577862237,
        738544131228408810877899501401,
        1259364878519558726929217176601,
        1008010020840510185943345843979,
        1091751292145929362278703826843,
        793740294757729426365912710779,
        1150777367270126864511515229247,
        763179896322263629934390422709,
        636578605918784948191113787037,
        1026431693628541431558922383259,
        1017462942498845298161486906117,
        734931478529974629373494426499,
        934230128883556339260430101091,
        960517171253207745834255748181,
        746815232752302425332893938923,
    ]

    def __init__(self):
        self.nonce_gen = NonceGenerator()

    def get_x(self, nonce: int) -> bytes:
        ret = b""
        for mod in self.MODS:
            p = ECPoint(self.BASE_POINT, mod)
            x = (nonce * p).x
            ret += x.to_bytes(13, "big")
        return ret

    def gen(self) -> bytes:
        nonce, key = self.nonce_gen.gen()
        x = self.get_x(nonce)

        return b"\x02\x03" + key + x

    def verify(self, inp: bytes) -> bool:
        assert len(inp) == 218

        nonce = self.nonce_gen.get(inp[2:10])
        self.BASE_POINT = (inp[0], inp[1])
        x = self.get_x(nonce)
        return inp[10:] == x


def handler(_signum, _frame):
    print("Time out!")
    exit(0)


def main():
    signal.signal(signal.SIGALRM, handler)
    signal.alarm(300)

    service = MyECCService()

    for _ in range(100):
        service.gen()

    while True:
        inp = input("> ")
        if inp == "G":
            payload = service.gen()
            print(f"Payload: {payload.hex()}")
        elif inp == "V":
            payload = bytes.fromhex(input("Payload: "))
            result = service.verify(payload)
            print(f"Result: {result}")
        elif inp == "P":
            payload = bytes.fromhex(input("Payload: "))
            answer = service.gen()

            if payload == answer:
                with open("flag.txt", "r") as f:
                    print(f.read())
            else:
                print("Wrong :(")
            exit(0)


if __name__ == "__main__":
    main()

We have to find the nonce which is multiplied to the base point for different curves.

We just have to break one of the 16 curves because the same nonce is used for all 16.


Factoring the orders of 16 curves gives is the result that 13th curve is factored well with small primes to use pohlig-hellman.


ex.sage

MODS = [
    942340315817634793955564145941,
    743407728032531787171577862237,
    738544131228408810877899501401,
    1259364878519558726929217176601,
    1008010020840510185943345843979,
    1091751292145929362278703826843,
    793740294757729426365912710779,
    1150777367270126864511515229247,
    763179896322263629934390422709,
    636578605918784948191113787037,
    1026431693628541431558922383259,
    1017462942498845298161486906117,
    734931478529974629373494426499,
    934230128883556339260430101091,
    960517171253207745834255748181,
    746815232752302425332893938923,
]


from pwn import *
from Crypto.Util.number import *
from hashlib import sha256

io = remote("my-ecc-service.chal.perfect.blue", 1337)

io.sendlineafter("> ", "G")
io.recvuntil("Payload: ")
recv_dat = bytes.fromhex(io.recvline().decode()[:-1])
assert len(recv_dat) == 218

original_key = recv_dat[2:10]
recv_dat = recv_dat[10:]

x_ = []

for i in range(16):
    x_.append(int.from_bytes(recv_dat[:13], "big"))
    recv_dat = recv_dat[13:]


E = EllipticCurve(GF(MODS[12]), [-3, 7])
G = E(2, 3)
print(G * 2)

P = E.lift_x(Integer(x_[12]))


n = G.order()

fac = list(factor(n))

print(fac)

moduli = []
remainder = []

for i, j in fac:
    mod = i**j
    _g_ = G * ZZ(n / mod)
    _q_ = P * ZZ(n / mod)

    dl = discrete_log(_q_, _g_, operation = "+")
    moduli.append(mod)
    remainder.append(dl)
    print(dl, mod)

nonce = long_to_bytes(crt(remainder,moduli))

if len(nonce) != 10:
    nonce = long_to_bytes(MODS[12] - bytes_to_long(nonce))

state = sha256(nonce + b'wow').digest()[:10]
key = sha256(state).digest()[:8]

io.sendlineafter("> ", "V")
io.sendlineafter("Payload: ", "0000" + bytes.hex(original_key) + "00" * 13 * 16)

io.sendlineafter("> ", "P")
io.sendlineafter("Payload: ", "0203" + bytes.hex(key) + "00" * 13 * 16)

io.interactive()



My ECC Service 2 (11 solves)

The code is almost the same to My ECC Service, but this time we have to break all 16 curves at the same time.

I’ll skip the challenge code.


When we look at the verify function, it is kinda obvious that we can manually set the base points.

I tried finding good base points that has well factoring order for all 16 curves. It didn’t work so well.



But while I was searching points, sage said error: u can't make the curve singular, it's dangerous.

Thankfully that’s how I got the idea for singular curve.


The curve is same for all 16 curves, $y^2 = x^3 - 3x + ?$ ($?$ is determined with the base point.) with different modulus only.

So a nice base point would make all curves singular. (2, 2) worked just fine.


Also I learned that god sage can discrete_log with even unexisting square roots in Galois Field.

sqrt(3) was needed, and it existed in none of the curves.


ex.sage

from pwn import *
from Crypto.Util.number import *
from hashlib import sha256
import os
from tqdm import tqdm

MODS = [
    942340315817634793955564145941,
    743407728032531787171577862237,
    738544131228408810877899501401,
    1259364878519558726929217176601,
    1008010020840510185943345843979,
    1091751292145929362278703826843,
    793740294757729426365912710779,
    1150777367270126864511515229247,
    763179896322263629934390422709,
    636578605918784948191113787037,
    1026431693628541431558922383259,
    1017462942498845298161486906117,
    734931478529974629373494426499,
    934230128883556339260430101091,
    960517171253207745834255748181,
    746815232752302425332893938923,
]

io = remote("my-ecc-service-2.chal.perfect.blue", 1337)

io.sendlineafter("> ", "G")
io.recvuntil("Payload: ")
recv_dat = bytes.fromhex(io.recvline().decode()[:-1])
assert len(recv_dat) == 218
original_key = recv_dat[2:10]
io.sendlineafter("> ", "V")
io.sendlineafter("Payload: ", "0202" + bytes.hex(original_key) + "00" * 13 * 16)

io.sendlineafter("> ", "G")
io.recvuntil("Payload: ")
recv_dat = bytes.fromhex(io.recvline().decode()[:-1])
assert len(recv_dat) == 218

recv_dat = recv_dat[10:]

x_ = []

for i in range(16):
    x_.append(int.from_bytes(recv_dat[:13], "big"))
    recv_dat = recv_dat[13:]

io.sendlineafter("> ", "V")
io.sendlineafter("Payload: ", "0000" + bytes.hex(original_key) + "00" * 13 * 16)

state = b""

chk = 0

for i in range(16):
    Zp = GF(MODS[i])
    gx, gy = Zp(2), Zp(2)
    x = Zp(x_[i])
    y1 = Zp(x^3 - 3 * x + 2).sqrt()
    y2 = -y1

    x -= 1
    gx -= 1

    sq = Zp(3).sqrt()

    g_morph = (gy + sq * gx) / (gy - sq * gx)
    p1_morph = (y1 + sq * x) / (y1 - sq * x)
    p2_morph = (y2 + sq * x) / (y2 - sq * x)

    logged1 = Integer(p1_morph.log(g_morph))
    logged2 = Integer(p2_morph.log(g_morph))

    logged = min(logged1, logged2)

    print(logged)
    assert logged < 256^10
    assert len(long_to_bytes(logged)) == 10
    state += long_to_bytes(logged)

assert chk == 0

new_state = b''


last = b"\x00" * 10
for i in range(16 - 1):
    hsh = sha256(state + int(i).to_bytes(1, 'big')).digest()[:10]
    new_state += hsh
    v = int.from_bytes(hsh, 'big')
    
    last = xor(last, hsh)

new_state += last
assert len(new_state) == 160

key = sha256(new_state).digest()[:8]

print(bytes.hex(key))

io.sendlineafter("> ", "P")
io.sendlineafter("Payload: ", "0203" + bytes.hex(key) + "00" * 13 * 16)




io.interactive()

Ironically I wasted a lot of time dealing with nonces and keys after I fully got 16 nonces. Doh!



Blocky - 4 (17 solves) & Blocky - 5 (8 solves)

(Unsolved)

It both uses square attack as I said before.

kiona provided me a nice link to study that.

https://www.davidwong.fr/blockbreakers/square_2_attack4rounds.html