Post

Patriot CTF

solutions of some challenges in Patriot CTF

Bigger is better

It is clearly a wiener attack RSA

1
2
3
4
5
6
7
N = 0xa0d9f425fe1246c25b8c3708b9f6d7747dd5b5e7f79719831c5cbe19fb7bab66ed62719b3fc6090120d2cfe1410583190cd650c32a4151550732b0fc97130e5f02aa26cb829600b6ab452b5b11373ec69d4eaae6c392d92da8bcbea85344af9d4699e36fdca075d33f58049fd0a9f6919f3003512a261a00985dc3d9843a822974df30b81732a91ce706c44bde5ff48491a45a5fa8d5d73bba5022af803ab7bd85250e71fc0254fcf078d21eaa5d38724014a85f679e8a7a1aad6ed22602465f90e6dd8ef95df287628832850af7e3628ad09ff90a6dbdf7a0e6d74f508d2a6235d4eae5a828ac95558bbdf72f39af5641dfe3edb0cdaab362805d926106e2af
e = 0x5af5dbe4af4005564908a094e0eabb0a921b7482483a753e2a4d560700cb2b2dc9399b608334e05140f54d90fcbef70cec097e3f75395d0c4799d9ec3e670aca41da0892a7b3d038acb7a518be1ced8d5224354ce39e465450c12be653639a8215afb1ba70b1f8f71fc1a0549853998e2337604fca7edac67dd1e7ddeb897308ebf26ade781710e6a2fe4c533a584566ea42068d0452c1b1ecef00a781b6d31fbab893de0c9e46fce69c71cefad3119e8ceebdab25726a96aaf02a7c4a6a38d2f75f413f89064fef14fbd5762599ca8eb3737122374c5e34a7422ea1b3d7c43a110d3209e1c5e23e4eece9e964da2c447c9e5e1c8a6038dc52d699f9324fd6b9
c = 0x731ceb0ac8f10c8ff82450b61b414c4f7265ccf9f73b8e238cc7265f83c635575a9381aa625044bde7b34ad7cce901fe7512c934b7f6729584d2a77c47e8422c8c0fe2d3dd12aceda8ef904ad5896b971f8b79048e3e2f99f600bf6bac6cad32f922899c00fdc2d21fcf3d0093216bfc5829f02c08ba5e534379cc9118c347763567251c0fe57c92efe0a96c8595bac2c759837211aac914ea3b62aae096ebb8cb384c481b086e660f0c6249c9574289fe91b683609154c066de7a94eafa749c9e92d83a9d473cc88accd9d4c5754ccdbc5aa77ba9a790bc512404a81fc566df42b652a55b9b8ffb189f734d1c007b6cbdb67e14399182016843e27e6d4e5fca
import owiener
from Crypto.Util.number import*
d = owiener.attack(e, N)
print(long_to_bytes(pow(c, d, N)))

idk_cipher

The encryption script encode.py uses a custom cipher to encrypt the flag. It splits xor’s first byte of key with both first and last byte of flag. Then, it xors the second byte of key with second and second last byte of flag and so on.

We even know what the key is so we can reverse the encryption

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
import base64

def decrypt(encoded_b64_str, srt_key='secretkey'):
    # Step 1: Base64 decode
    encoded_val = base64.b64decode(encoded_b64_str).decode()
    
    # Step 2: Initialize empty arrays for the decrypted input
    half_len = len(encoded_val) // 2
    dec_p1 = []
    dec_p2 = []

    # Step 3: XOR decryption (reverse of encryption)
    for i in range(half_len):
        # XOR the encrypted characters with the secret key to get the original characters
        c1 = chr(ord(encoded_val[i * 2]) ^ ord(srt_key[i % len(srt_key)]))
        c2 = chr(ord(encoded_val[i * 2 + 1]) ^ ord(srt_key[i % len(srt_key)]))
        dec_p1.append(c1)
        dec_p2.append(c2)
    
    # Step 4: Rebuild original input from the two halves
    original_input = ''.join(dec_p1) + ''.join(dec_p2[::-1])
    
    return original_input

# Example usage:
encoded_b64_str =  "QRVWUFdWEUpdXEVGCF8DVEoYEEIBBlEAE0dQAURFD1I="
decrypted_text = decrypt(encoded_b64_str)
print("Decrypted Text:", decrypted_text)

Hard_implement

It is byte-at-a-time ECB decryption according to this page. We can reduce by only testing on string.printable

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
from pwn import *
import string

host = 'chal.competitivecyber.club'
port = 6001

flag_charset = string.printable

flag = 'pctf{'
junk = 'A' * 16

def encrypt(io, data):
    io.sendlineafter(b'Send challenge > ', data.encode())
    io.recvuntil(b'Response > ')
    return io.recvline().strip()

io = remote(host, port)

p = log.progress('Flag: ')

while flag[-1] != '}':
    payload = junk[:-(len(flag)+1)]
    truth = encrypt(io, payload)[:32]
    for c in flag_charset:
        p.status(f'{flag}{c}')
        if truth == encrypt(io, payload + flag + c)[:32]:
            flag += c
            break

print(f"{flag = }")
io.close()

High-roller

The script used by adversary is BestEncrypt.py which uses pycryptodome library to generate RSA keypair.

BestEncrypt.py

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
#! /usr/bin/python3.10
from Crypto.Util.number import *
from Crypto.PublicKey import RSA
import random
import time

random.seed(int(time.time()))
p, q = getPrime(512, random.randbytes), getPrime(512, random.randbytes)
n = p*q
e = getPrime(512)
phi = (p-1)*(q-1)

assert GCD(e, phi) == 1
d = pow(e, -1, phi)

key = RSA.construct((n, e, d, p, q))
with open("public_key.pem", "wb") as f:
    f.write(key.publickey().export_key("PEM"))

with open("private_key.pem", "wb") as f:
    f.write(key.export_key("PEM"))

The seed was used is int(time.time()) and converted into .pem file so we can use command to find time

1
2
stat -c '%n %y' public_key.pem
public_key.pem 2024-09-21 22:39:18.000000000 +0530 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
from Crypto.Util.number import *
from Crypto.PublicKey import RSA
import random
import time

pubkey = RSA.import_key(open("public_key.pem", "r").read())
c = bytes_to_long(open("flag.enc", "rb").read())
time_i = 1726938600

for i in range(time_i, 1, -1):
    print(time_i - i)
    random.seed(i)
    p, q = getPrime(512, random.randbytes), getPrime(512, random.randbytes)

    if p*q == pubkey.n:
        break

d = pow(pubkey.e, -1, (p-1)*(q-1))
print(long_to_bytes(pow(c, d, pubkey.n)))

Bit-by-bit

transmit.py

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
#!/usr/bin/python3
import sys

blocksize = 16

def loadMessage():
    message = ""
    with open("message.txt","r",encoding='utf-8') as file:
        for line in file:
            message += line
    while len(message) % blocksize != 0:
        message += '0'
    return message

def encode(chunk):
    start = 120
    encoded = 0
    for c in chunk:
        encoded = encoded | (ord(c)<<start)
        start -= 8
    return encoded

def transmit():

    # Test key
    key = 0xadbeefdeadbeefdeadbeef00
    # print(key)
    iv = 0
    msg = loadMessage()
    chunks = [msg[i:i+16] for i in range(0,len(msg), 16)]

    send = ''
    for chunk in chunks:
        iv = (iv+1) % 255
        curr_k = key+iv
        encoded = encode(chunk)
        enc = encoded ^ curr_k
        foo = hex(enc)[2:]
        while len(foo) != 32:
           foo = '0'+foo
        send += foo
    print(send)
    sys.exit(0) 

if __name__=="__main__":
    transmit()

The encode fuction tranform a bytes object to a number like: ababab… -> 01100001.01100010

So the first time I think about xor between $enc$^$enc[256]$ because they will share the same keyxor which % 256 then I stuck :v. The solution is exploit key fixing and repeated so it is like this page

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
#!/usr/bin/env python3

from re import findall
from Crypto.Util.number import long_to_bytes as ltb, bytes_to_long as btl
freq = {
    ord('a'): 0.08167, ord('A'): 0.08167,
    ord('b'): 0.01492, ord('B'): 0.01492,
    ord('c'): 0.02782, ord('C'): 0.02782,
    ord('d'): 0.04253, ord('D'): 0.04253,
    ord('e'): 0.12702, ord('E'): 0.12702,
    ord('f'): 0.02228, ord('F'): 0.02228,
    ord('g'): 0.02015, ord('G'): 0.02015,
    ord('h'): 0.06094, ord('H'): 0.06094,
    ord('i'): 0.06966, ord('I'): 0.06966,
    ord('j'): 0.00153, ord('J'): 0.00153,
    ord('k'): 0.00772, ord('K'): 0.00772,
    ord('l'): 0.04025, ord('L'): 0.04025,
    ord('m'): 0.02406, ord('M'): 0.02406,
    ord('n'): 0.06749, ord('N'): 0.06749,
    ord('o'): 0.07507, ord('O'): 0.07507,
    ord('p'): 0.01929, ord('P'): 0.01929,
    ord('q'): 0.00095, ord('Q'): 0.00095,
    ord('r'): 0.05987, ord('R'): 0.05987,
    ord('s'): 0.06327, ord('S'): 0.06327,
    ord('t'): 0.09056, ord('T'): 0.09056,
    ord('u'): 0.02758, ord('U'): 0.02758,
    ord('v'): 0.00978, ord('V'): 0.00978,
    ord('w'): 0.02360, ord('W'): 0.02360,
    ord('x'): 0.00150, ord('X'): 0.00150,
    ord('y'): 0.01974, ord('Y'): 0.01974,
    ord('z'): 0.00074, ord('Z'): 0.00074,
    ord(' '): 0.13000
}

with open("out.txt", "r") as f:
    ciphertext = bytes.fromhex(f.read())

chunks = [ciphertext[i:i+16] for i in range(0, len(ciphertext), 16)]
print(len(chunks)%16)
key = bytearray(16)

for i in range(0, 16):
    best_b = 0
    high_score = -1.0
    for j in range(256):
        key[i] = j
        score = 0.0
        for chunk in chunks:
            c = btl(chunk)
            k = btl(key)
            b = ltb(c ^ k)[i]
            try:
                score += freq[b]
            except:
                pass
        if score > high_score:
            best_b = j
            high_score = score
    key[i] = best_b

plaintext = b""
for i in range(len(chunks)):
    c = btl(chunks[i])
    k = (btl(key) + i + 1)
    plaintext += ltb(c ^ k)

plaintext = plaintext.decode("utf-8", "ignore")
print(plaintext)
# pctZ{4_th3_tw0_t1m3c4a324510356} <- the flag

Textbook Schnorr right??

The server gave me a Schnorr_signature

1
2
3
4
5
6
7
    def verify(self, message, signature):
        s, R = signature
        R_int = int(R.xy()[0] + R.xy()[1])
        h = self.compute_hash(R_int | message)
        left_side = s * self.generator
        right_side = R + h * self.public_key
        return left_side == right_side

But the difference is function compute_hash \(h = hash(R|m)\)

This gave us a potential way of bruteforcing to find $s$ and $R$ in which \((R | message) == target\)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
separator = "FF"
payload = "7F7F7F"
io.sendlineafter(b"Enter separator as a hex value (2 digits): ", separator.encode().strip())
io.sendlineafter("Enter 3-letter word 1 as a hex value (6 digits): ",payload.encode().strip())
io.sendlineafter("Enter 3-letter word 2 as a hex value (6 digits): ",payload.encode().strip())
io.sendlineafter("Enter 3-letter word 3 as a hex value (6 digits): ",payload.encode().strip())
io.sendlineafter("Enter 3-letter word 4 as a hex value (6 digits): ",payload.encode().strip())
io.sendlineafter("Enter 3-letter word 5 as a hex value (6 digits): ",payload.encode().strip())
io.sendlineafter("Enter 3-letter word 6 as a hex value (6 digits): ",payload.encode().strip())
io.sendlineafter("Enter 3-letter word 7 as a hex value (6 digits): ",payload.encode().strip())
io.sendlineafter("Enter 3-letter word 8 as a hex value (6 digits): ",payload.encode().strip())

TARGET = 0x7FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF
MESSAGE_OR_MASK = int("7F7F7FFF" * 8, 16)

def compute_hash(target):
    hash_int = int(hashlib.sha256(str(target).encode()).hexdigest(), 16)
    return hash_int % n

def verify_sig(s, R_test):
    R_test_str = str(R_test).strip("()")
    R_test_parts = [part.strip() for part in R_test_str.split(":")]
    xR = R_test_parts[0]
    yR = R_test_parts[1]
    sendxR = hex(xR)[2:]
    sendyR = hex(yR)[2:]
    send_s = hex(s)[2:]
    print(f"{sendxR = }")
    print(f"{sendyR = }")
    print(f"{send_s = }")

    io.sendlineafter(b"Enter the x-coordinate of signature R (in hex):", sendxR)
    io.sendlineafter(b"Enter the y-coordinate of signature R (in hex):", sendyR)
    io.sendlineafter(b"EEnter signature s (in hex): ", send_s)
    io.interactive()

from tqdm import*
def bruforce_signature(P, G):
    print("Brute-forcing to find a valid signature...")
    h = compute_hash(TARGET)
    hP = h*P
    s = secrets.randbelow(n)
    sG = s*G
    with tqdm(total=0, unit=' iterations', unit_scale=True) as pbar:

        while True:
            R_test = sG - hP
            R_test_binary = int(R_test.xy()[0] + R_test.xy()[1])
            if (R_test_binary | MESSAGE_OR_MASK) == TARGET:
                assert s*G - h*P == R_test
                print("found")
                verify_sig(s, R_test)
                break
            s = (s*2) %n
            sG = 2*sG
            pbar.update(int(1))
bruforce_signature(P, G)
This post is licensed under CC BY 4.0 by the author.