I participated in AeroCTF as a member of Super Guesser. We reached 1st place :)

We were able to grab first bloods on all three cryptography challenges.

Phoenix was solved by rbtree, and I solved horcrux and boggart.

 

Phoenix

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
#!/usr/bin/env sage
 
= 2
= GF(p)
P.<x> = PolynomialRing(F)
 
 
class Cipher:
    def __init__(self, size, params):
        self.size = size
        self.params = params
 
    def sequence(self, key):
        while True:
            key = key * self.params[0]
            yield key + self.params[1]
 
    def encrypt(self, key, data, strength):
        for value, pbit in zip(self.sequence(key), data):
            xbit = sum(value[i] for i in range(0, strength, 2))
            ybit = mul(value[i] for i in range(1, strength, 2))
            
            yield int(pbit) ^^ int(xbit) ^^ int(ybit)
 
 
def main():
    size = 256
    length = 1024
    strength = 10
 
    q = P.irreducible_element(size, 'minimal_weight')
    R.<x> = P.quo(q)
 
    key, a, b = [R.random_element() for _ in range(3)]
 
    with open('flag.txt''rb'as file:
        flag = file.read().strip()
 
    message = int.from_bytes(flag, 'big')
    assert message.bit_length() < size
    plaintext = list(map(int, bin(message)[2:]))
    padding = [0* (length - len(plaintext))
 
    cipher = Cipher(size, [a, b])
    ciphertext = list(cipher.encrypt(key, padding + plaintext, strength))
    result = int(''.join(map(str, ciphertext)), 2)
 
    print(a)
    print(b)
    print(result)
 
 
if __name__ == '__main__':
    main()
 
cs

 

Solution (due to rbtree)

Let's look at the encryption scheme. 

 

We have a key $k \in GF(2^{256})$ and known parameters $a, b \in GF(2^{256})$.

To encrypt a plaintext $m$, it is first converted to binary then padded at the front with zeros to have length 1024. 

Then, we take the first 10 bits of the polynomial representation of $a^i k +b$ for each $i$, and calculate $$xbit_i = \sum \text{(even index bits)}$$ $$ybit_i = \prod \text{(odd index bits)}$$ where calculations are done in $GF(2)$. 

Finally, we encrypt our text with a stream cipher, i.e. XOR our plaintext with $\{xbit_i \oplus ybit_i\}_{i \ge 1}$. 

 

We now make some observations.

  • It's very likely that $ybit_i$ is $0$, since it is a product of 5 bits. Heuristically, it has a $31/32$ chance of being $0$. 
  • The message bit length is guaranteed to be less than 256. This means the length of the padding is at least 768.
  • If we write each 256 coefficients of $k$ as variables, then $xbit_i$ can be written as a linear combination of them (and $0, 1$)

 

Now we have our solution idea.

  • If we assume that $ybit_i = 0$ and know the ptxt/ctxt bits, we can recover $xbit_i$. This gives us a linear equation over our 256 variables.
  • Of course, we know 768 bits of the plaintext and all of the ciphertext, so we can do this for 768 different values of $i$.
  • We take 256 equations out of those 768 and hope that the system of equations have a unique solution.
  • If there is a unique solution, we can recover the key and perform the decryption process to get a candidate for our flag.
  • If our recovered flag has "Aero{" in it, we can finish the challenge. If not, we try another set of 256 equations.

 

We need to have $ybit = 0$ for all 256 bits/equations we selected. This has a probability of $$(31/32)^{256} \approx 0.0003$$ of succeeding, and we also need our system to be uniquely solvable. Thankfully, our algorithm terminates in about 2000 trials, and each trial doesn't take too long. It seems like SageMath is very efficient when it comes to matrices over $GF(2)$ :) 

 

Here's the solution script, by rbtree. It runs in a few minutes.

 

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
70
71
72
73
74
75
76
77
78
79
80
import random
from Crypto.Util.number import *
 
= 2
= GF(p)
P.<x> = PolynomialRing(F)
 
size = 256
length = 1024
strength = 10
 
= P.irreducible_element(size, 'minimal_weight')
R.<x> = P.quo(q)
 
class Cipher:
    def __init__(self, size, params):
        self.size = size
        self.params = params
 
    def sequence(self, key):
        while True:
            key = key * self.params[0]
            yield key + self.params[1]
 
    def encrypt(self, key, data, strength):
        for value, pbit in zip(self.sequence(key), data):
            xbit = sum(value[i] for i in range(0, strength, 2))
            ybit = mul(value[i] for i in range(1, strength, 2))
            
            yield int(pbit) ^^ int(xbit) ^^ int(ybit)
 
enc = 69824286833704501471834043923417254326103912707315595840737453739249974863266259092449058810542265536810346421685955365128856715192808287450464619418781355923155781710833586631897182535937891456025282049302526058466298304955387306232279075295308862156912873485647349272079984781574084434511227361370780842056
enc = list(map(int, bin(enc)[2:]))
enc = [0* (length - len(enc)) + enc
 
= x^255 + x^252 + x^246 + x^245 + x^240 + x^239 + x^236 + x^234 + x^233 + x^232 + x^231 + x^229 + x^228 + x^227 + x^224 + x^223 + x^218 + x^217 + x^216 + x^215 + x^210 + x^209 + x^204 + x^202 + x^200 + x^198 + x^197 + x^196 + x^192 + x^188 + x^187 + x^182 + x^181 + x^180 + x^178 + x^177 + x^176 + x^174 + x^173 + x^172 + x^167 + x^166 + x^161 + x^160 + x^157 + x^155 + x^154 + x^151 + x^150 + x^149 + x^148 + x^147 + x^146 + x^144 + x^140 + x^137 + x^135 + x^133 + x^132 + x^130 + x^129 + x^126 + x^122 + x^119 + x^118 + x^115 + x^112 + x^111 + x^109 + x^107 + x^106 + x^105 + x^104 + x^101 + x^100 + x^99 + x^97 + x^96 + x^94 + x^92 + x^87 + x^86 + x^84 + x^83 + x^81 + x^79 + x^75 + x^71 + x^69 + x^68 + x^67 + x^66 + x^65 + x^63 + x^62 + x^61 + x^56 + x^55 + x^53 + x^52 + x^50 + x^46 + x^44 + x^43 + x^41 + x^39 + x^38 + x^37 + x^36 + x^35 + x^34 + x^33 + x^32 + x^30 + x^29 + x^27 + x^24 + x^21 + x^17 + x^16 + x^14 + x^13 + x^12 + x^11 + x^10 + x^9 + x^5 + x^4 + x^3 + x + 1
= x^255 + x^254 + x^250 + x^247 + x^243 + x^242 + x^241 + x^238 + x^235 + x^232 + x^229 + x^227 + x^222 + x^221 + x^219 + x^218 + x^217 + x^216 + x^215 + x^211 + x^207 + x^206 + x^204 + x^202 + x^201 + x^197 + x^195 + x^193 + x^192 + x^190 + x^189 + x^188 + x^186 + x^184 + x^181 + x^180 + x^179 + x^178 + x^176 + x^173 + x^172 + x^169 + x^167 + x^165 + x^161 + x^160 + x^158 + x^149 + x^147 + x^146 + x^145 + x^140 + x^138 + x^137 + x^134 + x^133 + x^132 + x^130 + x^129 + x^128 + x^126 + x^125 + x^124 + x^121 + x^120 + x^118 + x^117 + x^114 + x^112 + x^111 + x^110 + x^109 + x^108 + x^107 + x^106 + x^105 + x^101 + x^96 + x^95 + x^94 + x^93 + x^92 + x^90 + x^89 + x^88 + x^86 + x^85 + x^84 + x^83 + x^81 + x^80 + x^79 + x^78 + x^77 + x^76 + x^71 + x^70 + x^69 + x^68 + x^67 + x^64 + x^63 + x^59 + x^56 + x^55 + x^53 + x^50 + x^46 + x^43 + x^42 + x^40 + x^38 + x^37 + x^35 + x^34 + x^33 + x^25 + x^23 + x^22 + x^21 + x^18 + x^16 + x^14 + x^13 + x^12 + x^11 + x^10 + x^8 + x^3 + x^2 + x
= sum(b[i] for i in range(0102))
 
vs = [ x^i for i in range(size) ]
vecs = []
for idx in range(length - size):
    for i in range(size):
        vs[i] = vs[i] * a
    
    arr = [int( sum(vs[j][i] for i in range(0102)) ) for j in range(size)]
    val = int(t) ^^ enc[idx]
 
    vecs.append( (arr, val) )
 
 
cipher = Cipher(size, [a, b])
cnt = 0
while True:
    cnt += 1
    random.shuffle(vecs)
 
    mat = Matrix(F, [vecs[i][0for i in range(size)])
    vec = Matrix(F, [vecs[i][1for i in range(size)]).transpose()
    try:
        vec = mat.inverse() * vec
 
        key = 0
        for i in range(size):
            if int(vec[i][0]):
                key += x^i
 
        plaintext = list(cipher.encrypt(key, enc, strength))
        plaintext = int(''.join(map(str, plaintext)), 2)
        plaintext = long_to_bytes(plaintext)
 
        if b'Aero{' in plaintext:
            while True:
                print(plaintext)
        else:
            print("FAIL2", cnt)
 
    except:
        print("FAIL", cnt)
        pass
cs

 

 

Horcrux

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
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
#!/usr/bin/env python3.8
 
from os import urandom
from gmpy2 import next_prime
from random import randrange, getrandbits
from Crypto.Cipher import AES
from fastecdsa.curve import Curve
 
 
def bytes_to_long(data):
    return int.from_bytes(data, 'big')
 
 
def generate_random_point(p):
    while True:
        a, x, y = (randrange(0, p) for _ in range(3))
        b = (pow(y, 2, p) - pow(x, 3, p) - a * x) % p
 
        if (4 * pow(a, 3, p) + 27 * pow(b, 2, p)) % p != 0:
            break
 
    return Curve(None, p, a, b, None, x, y).G
 
 
class DarkWizard:
    def __init__(self, age):
        self.power = int(next_prime(getrandbits(age)))
        self.magic = generate_random_point(self.power)
        self.soul = randrange(0self.power)
 
    def create_horcrux(self, location, weakness):
        # committing a murder
        murder = self.cast_spell(b'AVADA KEDAVRA')
 
        # splitting the soul in half
        self.soul = self.soul * pow(2-1self.power) % self.power
 
        # making a horcrux
        horcrux = (self.soul + murder) * self.magic
 
        # nobody should know location and weakness of the horcrux
        horcrux.x ^= location
        horcrux.y ^= weakness
 
        return horcrux
 
    def cast_spell(self, spell_name):
        spell = bytes_to_long(spell_name)
 
        return spell %~ spell
 
 
def encrypt(key, plaintext):
    cipher = AES.new(key=key, mode=AES.MODE_ECB)
    padding = b'\x00' * (AES.block_size - len(plaintext) % AES.block_size)
 
    return cipher.encrypt(plaintext + padding)
 
 
def main():
    wizard_age = 3000
    horcruxes_count = 2
 
    wizard = DarkWizard(wizard_age)
    print(f'Wizard\'s power:\n{hex(wizard.power)}\n')
    print(f'Wizard\'s magic:\n{wizard.magic}\n')
 
    key = urandom(AES.key_size[0])
    horcrux_length = len(key) // horcruxes_count
 
    for i in range(horcruxes_count):
        key_part = key[i * horcrux_length:(i + 1* horcrux_length]
 
        horcrux_location = bytes_to_long(key_part[:horcrux_length // 2])
        horcrux_weakness = bytes_to_long(key_part[horcrux_length // 2:])
 
        horcrux = wizard.create_horcrux(horcrux_location, horcrux_weakness)
        print(f'Horcrux #{i + 1}:\n{horcrux}\n')
 
    with open('flag.txt''rb'as file:
        flag = file.read().strip()
 
    ciphertext = encrypt(key, flag)
    print(f'Ciphertext:\n{ciphertext.hex()}')
 
 
if __name__ == '__main__':
    main()
 
cs

 

Solution

We first take a look at what's going on.

  • We have a 3000 bit prime $p$, which we know.
  • We have a point $G$ on some elliptic curve $ y^2 = x^3 + ax + b$ over $\mathbb{F}_p$ - we know $G$ but we don't know $a, b$.
  • We have a 128 bit AES key we have to recover : this is split into two "horcruxes" of 64 bits.
  • Each horcrux is split into two parts of 32 bits - they are XOR'ed to the coordinates of some point on the curve then given.

 

Writing this in mathematical equations, we have

  • We are given a 3000 bit prime $p$ and three coordinates $(x_0, y_0), (x_1, y_1), (x_2, y_2)$
  • 128 bit AES key is equal to $\alpha_1 || \beta_1 || \alpha_2 || \beta_2$ with each part being 32 bits, i.e. less than $2^{32}$.
  • $(x_0, y_0)$ lies on a elliptic curve $y^2 = x^3 + ax + b$ over $\mathbb{F}_p$, so $y_0^2 = x_0^3 + ax_0 + b$ in $\mathbb{F}_p$.
  • $(x_1 \oplus \alpha_1, y_1 \oplus \beta_1)$ lies on the curve, so $(y_1 \oplus \beta_1)^2 = (x_1 \oplus \alpha_1)^3 + a(x_1 \oplus \alpha_1) + b$
  • $(x_2 \oplus \alpha_2, y_1 \oplus \beta_2)$ lies on the curve, so $(y_2 \oplus \beta_2)^2 = (x_2 \oplus \alpha_2)^3 + a(x_2 \oplus \alpha_2) + b$

 

Since XOR is hard to deal with, we change it to normal addition - of course, it's clear that

  • $U, V \ge 0$, $V < 2^b$ implies $|(U \oplus V) - U| < 2^b$. 

Therefore, we can change each XOR to addition, by changing our constraints from $0 \le \alpha_i, \beta_i < 2^{32}$ to $|\alpha_i|, |\beta_i| < 2^{32}$.

 

Now we have to solve, given $p, x_0, y_0, x_1, y_1, x_2, y_2$, the system of equations $$\begin{equation*} \begin{aligned}  y_0^2 &= x_0^3 + ax_0 + b \\ (y_1 + \beta_1)^2 &= (x_1 + \alpha_1)^3 + a(x_1 + \alpha_1) + b \\ (y_2 + \beta_2)^2 &= (x_2 + \alpha_2)^3 + a(x_2 + \alpha_2) + b \end{aligned} \end{equation*}$$ over $\mathbb{F}_p$ and $|\alpha_i|, |\beta_i| < 2^{32}$. Now what do we do? There were a few ideas at this point...

 

Idea 1 : Lattices

Of course, "small solutions" imply that we have to use lattices. Maybe we can directly use them here?

For example, using github.com/rkm0959/Inequality_Solving_with_CVP we can solve a system of linear inequalities.

Turns out, it's quite difficult to do so, since our system has terms like $a \alpha_1$ and $a \alpha_2$. 

Because we don't know the value of $a$ and its size can be large, we can't deal with this term using this method.

 

To use the tool in the link, we need all terms of our equations to be either

  • linear in terms of our variables (i.e. variable multiplied by a known constant)
  • small (so that we can move this to the lower bound / upper bound vector for the CVP)

In this case, $a \alpha_1$ and $a \alpha_2$ term does not satisfy this requirement.

 

Idea 2 : Determinant

The failure of our Idea 1 leads to the key idea : removing $a$ in our system completely.

 

This can be done by realizing that $[1, -a, -b]^{T}$ is in the kernel of the matrix $$ \left[ \begin{matrix} y_0^2 - x_0^3 & x_0 & 1 \\ (y_1 + \beta_1)^2 - (x_1 + \alpha_1)^3 & (x_1 + \alpha_1) & 1 \\ (y_2 + \beta_2)^2 - (x_2 + \alpha_2)^3 & (x_2 + \alpha_2) & 1 \end{matrix} \right]$$ which means its determinant must be $0$ in $\mathbb{F}_p$. This gives us a polynomial equation in $\alpha_1, \alpha_2, \beta_1, \beta_2$. Now what?

 

The first idea was multivariable coppersmith - I tried to use defund's implementation, but the matrix size got way too large.

The second idea is, of course, lattices from Idea 1. If we expand our determinant, we will get a equation of the form $$\sum \text{(constant coefficient)} \cdot \text{(some monomial)} = 0$$ we can simply bound each monomials with $|\alpha_i|, |\beta_i| < 2^{32}$, then solve using the CVP toolkit above.

 

To be more precise : our equation will be in a form of $$\sum_{i=1}^k c_i m_i \equiv -c_{k+1} \pmod{p}$$ where $c_i$ is a known constant and $m_i$ is some non-constant monomial over $\alpha_i$, $\beta_i$. (Consider $m_{k+1}$ as $1$)

We solve this system by utilizing the following inequalities and plugging them into the CVP toolkit : 

$$-2^{32 \cdot \deg(m_i)} < m_i < 2^{32 \cdot \deg(m_i)}$$ $$ \sum_{i=1}^k c_i m_i+ pX = -c_{k+1}$$ where $\deg(m_i)$ is the degree of the monomial. Note that abuse of notation was used - $m_i$ is used both as a monomial, and as the actual value of the monomial with the true values of $\alpha_i$, $\beta_i$ plugged in. For further details, please take a look in the github repo and the solution.

 

UPD : Mystiz gives a good insight on entropy to explain why this line of thinking works. Link is here.

This is a very useful way to think about whether or not the above CVP toolkit will work, when it comes to the uniqueness of the solution.

 

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
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
from sage.modules.free_module_integer import IntegerLattice
 
# https://github.com/rkm0959/Inequality_Solving_with_CVP
def Babai_CVP(mat, target):
    M = IntegerLattice(mat, lll_reduce=True).reduced_basis
    G = M.gram_schmidt()[0]
    diff = target
    for i in reversed(range(G.nrows())):
        diff -=  M[i] * ((diff * G[i]) / (G[i] * G[i])).round()
    return target - diff
 
 
def solve(mat, lb, ub, weight = None):
    num_var  = mat.nrows()
    num_ineq = mat.ncols()
 
    max_element = 0 
    for i in range(num_var):
        for j in range(num_ineq):
            max_element = max(max_element, abs(mat[i, j]))
 
    if weight == None:
        weight = num_ineq * max_element
 
    if len(lb) != num_ineq:
        print("Fail: len(lb) != num_ineq")
        return
 
    if len(ub) != num_ineq:
        print("Fail: len(ub) != num_ineq")
        return
 
    for i in range(num_ineq):
        if lb[i] > ub[i]:
            print("Fail: lb[i] > ub[i] at index", i)
            return
 
    # scaling process begins
    max_diff = max([ub[i] - lb[i] for i in range(num_ineq)])
    applied_weights = []
 
    for i in range(num_ineq):
        ineq_weight = weight if lb[i] == ub[i] else max_diff // (ub[i] - lb[i])
        applied_weights.append(ineq_weight)
        for j in range(num_var):
            mat[j, i] *= ineq_weight
        lb[i] *= ineq_weight
        ub[i] *= ineq_weight
 
    # Solve CVP
    target = vector([(lb[i] + ub[i]) // 2 for i in range(num_ineq)])
    result = Babai_CVP(mat, target)
 
    for i in range(num_ineq):
        if (lb[i] <= result[i] <= ub[i]) == False:
            print("Fail : inequality does not hold after solving")
            break
 
    ## recover your result
    return result, applied_weights
 
= 0x27266a1284b761f793a529b9664693a6b1db36864a8664898d98d8010ec51afffaef2d1c79ab2078c1e0b289a1719d34b0a081ca325ba2b367017ee8e0824aaa9488409e76e923d0f0fc917ddfc1b0534e93a74246405dadfd1683f0dc31682eed0fa6b95fc235c845e16d2ef40463b7e746668dad82981fc4e05933aca410c65b36f89738f7d97502f6626c38f595338f3864638d8613fb74c16b63f3969a49ebd103ef354ed756c3cd5e67f1d2dbe5acdbc088bd6c1d503acef4ec59e4a09efac4729ca796ad25217fe74e7a0c7ef5a3e1fcd9eb9288fb89e842ef0b16642f7e84e27df4bcb623726e2c44ef46be07f9b5a5f92fe2c77d0de79fa6d46193b064207125d8935c2ff04f63e2f858e98d2518077dc58e13307f01d65ae953efd70980f3aeed320b7a6b66eb0c578dc3f05d426f412c4e3c7a9bcc68f27fe236fde41400371a39f53828824f5de3d5902cd3e7dcaee58b89c1a234188e391d412e7bc598d4f10b2bcb26aab7cd09194e80be046022ee8471
 
x0 = 0x4f69c16e493693a9342b7b9bb0ae42d0e7425996151c631a5620ad4d7ed372d285f04df82975a379080af08322fd927cf8ea9702f4533b5981351dd12358ca61c6e34af3f902b13d2981cae7cd2416e910bfe2c90f3b64c02bf933b1d11ff8ee384f5b507d9a0b1be38b15e7db3ba700ed32122a0163ec66530911fe142e098187771f5a248627a1709800069d402c1a61a17bd053ff6541f981898df6670420b0f3ea6ebd6d01d24f6fe9e3091a7c36df0c1ad7596e8ae090c54528385422911ff8e1dc201ab56844bc92acabc495c442104de5c34a5fe6661b7213500f4ccef6e76b94c34d60772fcf0c1250e66812d85efcda4b323250f740d0d57d40697fedae0bdbaceb0582cd7c82a27610e7f9fba5ac8f84e006d034dcf481f2dc9338acabd51c28afc1b4fc1d0cd7c1cd5ad21109e8708e9458e5301868c68920ab1aefb1e9184383002e6c893f1793443f8388f3f1b6e1f4ecaecb4cf000f57f677156efdacb20d60a35b8337ee416957aadffdc889dce487
y0 = 0x2393c955e5f44ab3ac052dafd83554222728393b8fd20630f3c4f122d8c86d872a7b692b3782f12a6e57352c46328f85fad2d73eaccfcbf7beb47f97da2148c4f3fc7d6751bf66569c97a64f732e7cc71767ba11b1419732035c0285ff6973fba230a3d315fba7820855208e07e6fc5bc4b2cc3868aacde3b8e9b2075bfe861b2ebccd9b6c836d85dc319290263961344d348fe8faa0aa3ebca76fe514a7b7ba313a8200727b22a8714f8bba9e5ec2c549e5bfb5857c050d1ff0471d4b01426c2ee583a7d4b6c30df5ad2d9a6902f574416f8d55ed192b29d521e5d23a54e5062400b539468dca9aa3dc558feac63c88fc696d42434ad9a83551e6860aeb6a4d84ca80713387fa8c56a1e473a82af63ec03a71202aba0ae46fb9a97132f4c92d332327e2c11b79008586b22d92d60c3155e88b5e1f9c193b363ca28f0990400afb6e8148458708b89c6023c0d5ebb746bcd754fe37a84ee4dea6baa273b2ef31a864e9586f01bae855cc0d6f6055b2546b2a918664bdb6
 
 
x1 = 0x2700ff7abe81679c770d3171c993c55da4a47cda3360e33d2b763e65517f307a39d401a256ee0634f3f1c6c244aad34422dccfe9ebd1803339972095eb2b0a57c82ee0db9ba94365cfb5270c4924c5c1ec00db2aff529aa923b113d2ca8ad6a6774fb7c655cd101ea63bc5a6ea0261f8da82d455219c7584d7de0757b2fda627c4684d3bac8f899c24178c7e0ecef6c226892b86043d3853cdb777889ce2901d8496bf0232dd000208eb2ce77e953c478551b1112ebf4b02f0086726210a50dc20ac08eb15d846084f9324b4f1f5ec73e3ef7e4a5207e04ebb866f731201e5f626084d1b61c158cfd0fdbaae8b8dd23ba599689c74a790933a3e77daa1c95fbde63b74381aff2b98f41cfebb7f1b220a4f2e8c3361734d7bd6648c720efc0a5f978917d8b84b4764e416762884f00104981e62d876d460bd8c1095cd755d8f31c5377e5ef935da77ff82e823b49d817a1f91bd4d155306173eb07efa4567d362e1cddbf0483873d5efc9286c36a58e5b3d995f3d01ca60
y1 = 0x1bf9a696ef976644dd903fb148892044fe65dd16bf12966a6b6e43be4b3b52aaa348a76ed5a53bbb400a366c59c96cf88a9c6a713273a722c0c8cd6f42b7c6f5e1911d2d323780bce65be80eef4d375dd3425a9ff832e4166765c7687aaf3c6f3c1ae7c561c46c49f0075bb68d48b95be5fd2c94996a42ba255eb638ae5ce449064cc81831f2239dbb93f4944693ee42a507dc2878988928dc9ed5bb5cb3b3eba9ac414b4171e505d285edaf02d13c0748e47b17a498cb0c15883977a11cde98995a022999a555fb3f1a3b9226dc4122f3db6c86036b1b0e6ada10b23b8c89ddb590f2186191c0f1148a04a8e35c905d4053943554c58e8f0e2ccac42c2307532e2b8f55b74fccb8ab24a977c557d10bd7c8621bef3e7f326d00c3ff28a52ee85ec61bb8aef14f67ac80da5885a93f2840a5e44d9800b0352163667ba851bb15c4083955e1fba5cd9d6e7bf103a2bbb0fbc868136cf2871815762514c691e6352af18324d1ccdc21da3e1c4c6aa771aa9fccf343ad64b8
 
x2 = 0xa439486ae1dbd96ca0796947609757b9dc068d8ed8287f98261c0c77e0940d29e25d27e16b97bc6983be505b8295886a5e880664ecb2d9161036f5beb1dbdac08da0cc2797d574e0feac67bbcb43275ab9e9d936aa17fd9a0a13a2f95e111b5e0893c4d4c3afae3bfa4a60fc5d0673215dd876b8bc960fc765401135dc708562cbeb572fcefaef1fef51782f6e0b495c0799cf89a9f47ffec11b0e780fb41e80d75681f7c9dd5fbf5e93e1dcda8091ca6a84de5b82b3abc9194913119e429781c12c11ebc6b6e80c01683344319879dec8fe59dd2f9c5b7b6e5e211153ae3b5fd161edae59777f3e76ecdeaef518495e512119451c6a97a8f471c4349ba7df8c3e9c1ba67f7a4fef57551d58b8c87607dc830c8074eec50841a1e365be90b528e4f39b8e19e7617191ac5118bb1abc44739d65384d915cb72e226122965ebae1581f55ecb12e523b0e904b3c0a1b26cf506ca030a68fe40d34c0912272277cd0d605ab057dbb5423cca28629661ad163232e766e80949
y2 = 0x29cfc760529c48d68bc086a5b4403f1d4446db04abe243c99baf659b5e67cd6cdac9a658f273f4c682b9e13dda72aaa1ede42c69c98640f2fc58eabeef143a65334e4a236a6e72a157d92ab1a541c9bcf7d953b386a40d68880312ed1e900f6ba481bf134515f5a4c245a0d924db0e5105fc44fae71fc991df85219403ba5d48f68d5d91151f8411b4cbb6971bd63030b523989fa7ec94c14136ac712d4e91eca4c930d79caab7c328043c762917c4b3f868ce037cae9f19a579272c6b13ca8c19d349f5777bf6ac9c078d8472c92582daf96b30d4f7b8fdc004a36b792c133e2b6511956480892636ea91a3361afd8a3afbe3a5bf889feb5a5dc143f5a917347591634218066f2f71b36afd5257f6637152ac9a0965daa881ddc86ca8a8545b255cbb14e7738297a55c7428d9a0e79dee37f801fc1d49d205be809e0cd1b8a1b9d1d5b1b1a9ae98e7c564718cb17d10a3dd01c2914d7bb96c45a4fc942c2ed628354882464407c3208938282471aa2b8016e46c998e4
 
 
= Integers(p)
 
# determinant calculation
# a, b = alpha_1, beta_1
# c, d = alpha_2, beta_2
P.<a, b, c, d> = PolynomialRing(R)
= 0
+= (y0 ** 2 - x0 ** 3* (x1 + a) * 1
+= (x0) * 1 * ((y2 + d) ** 2 - (x2 + c) ** 3)
+= 1 * ((y1 + b) ** 2 - (x1 + a) ** 3* (x2 + c)
-= (y0 ** 2 - x0 ** 3* 1 * (x2 + c)
-= x0 * ((y1 + b) ** 2 - (x1 + a) ** 3* (1)
-= 1 * (x1 + a) * ((y2 + d) ** 2 - (x2 + c) ** 3)
 
# coefficient
= f.coefficients()
 
# degree
print(f.monomials())
# [a^3*c, a*c^3, a^3, a^2*c, b^2*c, a*c^2, c^3, a*d^2, a^2, b^2, a*c, b*c, c^2, a*d, d^2, a, b, c, d, 1]
= [poly.degree() for poly in f.monomials()]
print(D)
# [4, 4, 3, 3, 3, 3, 3, 3, 2, 2, 2, 2, 2, 2, 2, 1, 1, 1, 1, 0]
 
 
# CVP setup
print(len(C)) # 20
 
= Matrix(ZZ, 2020)
lb = [0* 20
ub = [0* 20
 
# monomial bounds
for i in range(019):
    M[i, i] = 1
    lb[i] = -(2 ** (32 * D[i]))
    ub[i] = (2 ** (32 * D[i]))
 
# the polynomial
for i in range(019):
    M[i, 19= C[i]
M[1919= p
lb[19= int((-C[19]) % p)
ub[19= int((-C[19]) % p)
 
# solve CVP
result, applied_weights = solve(M, lb, ub)
 
# recover variables
alpha_1 = result[15// applied_weights[15]
beta_1 = result[16// applied_weights[16]
alpha_2 = result[17// applied_weights[17]
beta_2 = result[18// applied_weights[18]
 
print(alpha_1, beta_1, alpha_2, beta_2)
 
# recover actual key
alpha_1 = x1 ^^ (x1 + alpha_1)
beta_1 = y1 ^^ (y1 + beta_1)
alpha_2 = x2 ^^ (x2 + alpha_2)
beta_2 = y2 ^^ (y2 + beta_2)
 
print(alpha_1, beta_1, alpha_2, beta_2)
# the rest is trivial decryption
cs

 

 

Boggart

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
70
71
72
73
#!/usr/bin/env python3.8
 
from gmpy import next_prime
from random import getrandbits
 
 
def bytes_to_long(data):
    return int.from_bytes(data, 'big')
 
 
class Wardrobe:
    @staticmethod
    def create_boggarts(fear, danger):
        # for each level of danger we're increasing fear
        while danger > 0:
            fear = next_prime(fear)
 
            if getrandbits(1):
                yield fear
                danger -= 1
 
 
class Wizard:
    def __init__(self, magic, year, experience):
        self.magic = magic
        self.knowledge = year - 1 # the wizard is currently studying the current year
        self.experience = experience
 
    def cast_riddikulus(self, boggart):
        # increasing the wizard's experience by casting the riddikulus charm
        knowledge, experience = self.knowledge, self.experience
 
        while boggart > 1:
            knowledge, experience = experience, (experience * self.experience - knowledge) % self.magic
            boggart -= 1
 
        self.experience = experience
 
 
def main():
    year = 3
    bits = 512
    boggart_fear = 31337
    boggart_danger = 16
 
    neutral_magic, light_magic, dark_magic = [getrandbits(bits) for _ in range(3)]
    magic = next_prime(neutral_magic | light_magic) * next_prime(neutral_magic | dark_magic)
 
    print('Hello. I am Professor Remus Lupin. Today I\'m going to show you how to deal with the boggart.')
 
    print(neutral_magic)
    print(magic)
 
    with open('flag.txt''rb'as file:
        flag = file.read().strip()
 
    # some young wizards without knowledge of the riddikulus charm
    harry_potter = Wizard(magic, year, bytes_to_long(b'the boy who lived'))
    you = Wizard(magic, year, bytes_to_long(flag))
 
    for boggart in Wardrobe.create_boggarts(boggart_fear, boggart_danger):
        # wizards should train to learn the riddikulus charm
        harry_potter.cast_riddikulus(boggart)
        you.cast_riddikulus(boggart)
 
    # wizard's experience should be increased
    print(harry_potter.experience)
    print(you.experience)
 
 
if __name__ == '__main__':
    main()
 
cs

 

Solution

There is a lot going on in this code, so let's take a look at it.

  • We have random 512 bit integer $M_N, M_L, M_D$ corresponding to neutral/light/dark magic
  • The value magic is created as $M = \text{next_prime}(M_N | M_L) \times \text{next_prime}(M_N | M_D)$.
  • We are given the values of $M_N$ and $M$ - so the factorization of $M$ is unknown.

Now, the text "the boy who lived" and the flag goes through some encryption process, and we know their ciphertexts.

  • The encryption process itself is quite random - it uses the cast_ridikkulus algorithm randomly.
  • To be more precise, it starts the parameter "fear" at 31337, and increases it to the next prime each time.
  • Then, with a 50% chance, the text gets encrypted with the said value of "fear" and the cast_ridikkulus algorithm.
  • If we have used the cast_ridikkulus algorithm 16 times, the encryption process terminates.

 

cast_riddikulus algorithm

 

Naturally, we have to look at the cast_riddikulus algorithm and how to reverse/decrypt it.

We note that the value of self.knowledge is fixed at $2$ at all times. Denote the self.experience as $x$, and the boggart as $n$.

 

The algorithm calculates a linear recurrence - it starts with $a_0=2$, $a_1 = x$, and moves up by $$a_{i} = xa_{i-1} - a_{i-2}$$ Let's forget about the modulos and solve this recurrence. Using standard techniques, we get $$a_n = \left( \frac{x - \sqrt{x^2 - 4}}{2} \right)^n + \left( \frac{x + \sqrt{x^2- 4}}{2} \right)^n $$ This looks kinda scary, but then we can see that this is just $$x = t + t^{-1} \implies a_n = t^n + t^{-n}$$ Note that this implication holds even when we consider the fact that we are working modulo $M$.

 

This has a lot of meaning - for example, the encryption function is commutative in a sense that encrypting a text (experience) with (boggart) $m$ then $n$ is equivalent to encrypting with $n$ then $m$, which is equivalent to encryption once with $mn=nm$. 

 

It also implies that under some "mild" conditions, we can reverse the encryption function as follows - 

  • Given $ctxt \equiv t^n +  t^{-n} \pmod{M}$, we solve for the value $t^n \pmod{M}$.
  • We solve for $t \pmod{M}$ using the value of $t^n \pmod{M}$.
  • We get our original value $ptxt = t + t^{-1} \pmod{M}$.

In the above algorithm, we needed

  • Factorization of $M$, and we need $t$ to be coprime to $M$
  • $t^n + t^{-n} \equiv ctxt \pmod{M}$ to be solvable, i.e. $ctxt^2 - 4$ being a quadratic residue
  • $n$ being coprime to $\phi(M)$, for the discrete root to be easy to solve 
  • (of course, we can use adlemann root extraction, but that's not very fast)

Let's review our requirements and how difficult they are

  • Factorization of $M$ : this is hard but must be done - and this is probably why we are given $M_N$.
  • $t$ being coprime to $M$ : this will probably hold even if we don't care about it
  • $ctxt^2 - 4$ being a quadratic residue : if not, we can just get our random values of $M_N, M_L, M_D, M$ again.
  • $n$ being coprime to $\phi(M)$ : in our context, we will use 16 primes larger than $30000$ as $n$, so it'll most likely be true.

Therefore, it's time to go back and try to factorize $M$ with our information of $M_N$.

 

factorization of $M$

 

The key idea is that the value of $M_N$ gives us known bits of each prime $p, q$ of $M$.

  • $M_N|M_L$ and $M_N|M_D$ obviously has all bits that $M_N$ has. 
  • Then, we apply the next_prime function. Due to the small prime gap, this is similar to adding a few thousand to each number.
  • Therefore, some lower bits will definitely change, and even higher bits can change due to carry

Consider $M'_N = M_N - (M_N \pmod{2^{16}})$, i.e. $M_N$ but changed the $16$ LSB to 0.

  • If no carry happens at the 16th bit, our primes will still have all the bits of $M'_N$.
  • If a carry happens at the 16th bit, we hope our primes will still have all the bits of $M'_N + 2^{16}$.

Therefore, we write $M''_N = M'_N \& (M'_N + 2^{16})$ and claim that $p, q$ all have the bits of $M''_N$.

 

It is known that with around 57% of the bits of $p, q$ known, we can factorize $N$ using the following algorithm -

  • At stage $k$, calculate all possible $(p \pmod{2^k}, q \pmod{2^k})$ such that $pq \equiv N \pmod{2^k}$.
  • Of course, we must respect the known bits of $p, q$ while calculating this.
  • We can change $p \pmod{2^k}$ to $p \pmod{2^{k+1}}$ or $p + 2^k \pmod{2^{k+1}}$ while moving to the next stage.
  • If we find a set of $(p, q)$ such that $pq = N$, the algorithm terminates. 
  • This can be written in a depth-first search style or a breadth-first search style. The latter works better on my machine.

We require that $M''_N$ has a lot of ones in its binary representation. We simply restart the connection until we get good parameters.

For the details of the factorization algorithm, please refer to the solution script below.

 

how did we encrypt this?

 

Now we know the factorization of $M$. We can now check if $ctxt^2 - 4$ is a quadratic residue modulo $M$.

We test this for the ciphertext of the "the boy who lived" and the ciphertext of the flag. If this check fails, we run our algorithm again with a new connection for a good set of parameters. We now assume $ctxt^2 - 4$ is a quadratic residue.

 

We reasonably guess that our encryption algorithm will have used 16 primes out of the 35 consecutive primes larger than 31337.

We also verify that $p-1$ and $q-1$ are all coprime to these 35 candidate primes. If not, we look for a good set of parameters.

 

Now we can encrypt and decrypt as we like. Let's find out which 16 primes were used for encryption.

 

To do this, we use meet-in-the-middle algorithm. Basically what we do is

  • Split the set of 35 primes into two pieces, and assume that 8 primes were used in each piece
  • Calculate the encryption of "the boy who lived" with each combination of 8 primes of the first piece
  • Calculate the decryption of the ciphertext with each combination of 8 primes of the second piece
  • Find a match, and if there is one, we have found our set of 16 primes.
  • If not, we can try a new split of the primes and continue on.

Now we know which primes were used for encryption. All we need to do is decrypt the ciphertext of the flag.

 

additional notes

 

Note 1 : speeding up cast_riddikulus algorithm

  • This is a linear recurrence, so we can speedup using matrix multiplication
  • However, this is also a Lucas sequence used in Williams's $p+1$ algorithm
  • Therefore, we can just copy the implementation from Wikipedia to achieve a speedup.

Note 2 : requirements relaxation

  • If we assume that the flag is small, we only need to work with mod $p$ or mod $q$, instead of mod $M$.
  • Therefore, we can relax our quadratic residue conditions to "QR modulo at least one of the primes"
  • Thankfully, I was very lucky with the parameters and got QR modulo $M$.

Note 3 : meet-in-the-middle, but not quite middle

  • If we try to run the code, we can see that encryption is faster than decryption.
  • Therefore, it's reasonable to give the encryption part more work than just "half" of the bunch.

The solution code is displayed in parts for reader's understanding. Feel free to comment and ask questions...

 

reading parameters

1
2
3
4
5
6
7
8
9
10
11
12
13
def recvint():
    T = r.recvline()
    return int(T[:-1].decode(), 10)
 
 
def get_vals():
    r.recvline()
    C = recvint()
    N = recvint()
    H = bytes_to_long(b'the boy who lived')
    H_AFTER = recvint()
    F_AFTER = recvint()
    return C, N, H_AFTER, F_AFTER
cs

 

factorization of $M$

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
def bsum(x):
    ret = 0
    while x > 0:
        ret += x % 2
        x //= 2
    return ret 
 
def factor(N, C, cand, k):
    if len(cand) == 0:
        return
    print(k, len(cand))
    # fix pq mod 2^{k+1} by adding 2^k or not
    ret = []
    if (C >> k) % 2 == 1# we know p, q has 2^k
        for p, q in cand:
            if p * q == N:
                print(p)
                print(q)
                return
            pp = p + (1 << k)
            qq = q + (1 << k)
            if (pp * qq - N) % (1 << (k + 1)) == 0 and pp * qq <= N:
                ret.append((pp, qq))
        factor(N, C, ret, k + 1)
    else:
        for p, q in cand:
            if p * q == N:
                print(p)
                print(q)
                return
            for i in range(02):
                for j in range(02):
                    pp = p + i * (1 << k)
                    qq = q + j * (1 << k)
                    if (pp * qq - N) % (1 << (k + 1)) == 0 and pp * qq <= N:
                        ret.append((pp, qq))
        factor(N, C, ret, k + 1)
 
# M_N to M''_N
-=  C % (1 << 16)
&= (C + (1 << 16))
# check number of 1s, if bsum(C) is small (<256), stop and rerun
print(C.bit_length(), bsum(C)) 
# factorize
factor(N, C, [(11)], 1)
cs

 

encryption and decryption

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
def mlucas(v, a, n): # encryption fast, from Wikipedia
    v1, v2 = v, (v**2 - 2) % n
    for bit in bin(a)[3:]:
        if bit == "0":
            v1, v2 = ((v1**2 - 2) % n, (v1*v2 - v) % n)
        else:
            v1, v2 = ((v1*v2 - v) % n, (v2**2 - 2) % n)
    return v1
 
def REV(val, ex, pr): # decryption modulo a prime
    # t^n + 1/t^n = val
    if val == 0# if invalid
        return 0 # return invalid
    cc = tonelli((val * val - 4) % pr, pr) # modular sqrt
    if cc == -1# failure
        return 0 # return failure
    tn = ((val + cc) * inverse(2, pr)) % pr
    t = pow(tn, inverse(ex, pr-1), pr)
    return ((t + inverse(t, pr))) % pr
cs

 

meet-in-the-middle

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
primes = [31337313573137931387313913139331397314693147731481314893151131513315173153131541315433154731567315733158331601316073162731643316493165731663316673168731699317213172331727317293174131751317693177131793]
 
= primes[15:35]
= primes[:15]
 
LV = []
RV = []
 
# generally, encryption is faster than decryption
# therefore, we leave the "heavy" work for the encryption part...
 
for combo_L in tqdm(itertools.combinations(L, 8)):
    CC = list(combo_L)
    cur = H
    # you can also just multiply all primes and encrypt at once
    for pr in combo_L:
        cur = mlucas(cur, pr, p)
    CC.append(cur)
    LV.append(CC)
 
print("LEFT DONE")
 
for combo_R in tqdm(itertools.combinations(R, 8)):
    CC = list(combo_R)
    cur = HAFTER
    # you can also just multiply all primes and decrypt at once
    for pr in combo_R:
        cur = REV(cur, pr, p)
    CC.append(cur)
    RV.append(CC)
 
print("RIGHT DONE")
 
for i in tqdm(range(len(LV))):
    for j in range(len(RV)):
        if LV[i][8== RV[j][8]:
            print("FOUND!!")
            print(LV[i])
            print(RV[j])
cs

 

the finish, and the parameters I got

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
= bytes_to_long(b'the boy who lived')
sys.setrecursionlimit(10 ** 6)
 
= 12124756848659098434025945489515506912896022954145117746118560512007665385702760439414990812257455576297349156226093149988609289245714223348281989890389750
= 157597985389833012337654133040126048344064622845161536236706459270343905778002470548499258715513540516431526518690532156245280894778788692043941237295606686168037171464988128988463706375526180496632421973522548093894845498612792150825707672843107252573999144787226703076358545319417530365329436368718460943493
HAFTER = 66161881147822169408519540711422900962287264738494143175834051626001922954586728648835878096124744364195826536091510407493007528877139856387261499433277826944946254511824024047480941829026088269865298686453128715170657018128276813244425143986311708022950785583195028647859774987948632731985531259912781472862
FAFTER = 149186530719822614329126547638374064715014252925601014676661223009475822460330945440469384214084001910035138025738722725987466200681944900264994344927428683388976167111544750466576538413516454786176229441173029050647235653998791477157269246962955063391947778663841551982999293815571149539542758304215156142104
 
-=  C % (1 << 16)
&= (C + (1 << 16))
 
# factor(N, C, [(1, 1)], 1)
 
= 12558711464274427739528720572494472142909592647353129013838950445222814801805965383430302364628487022743397586481672449715551542652546057434522020868473011
= 12548897698474048380978452887676419841595083766206501465313606366388795637681128899285066184154566275021196792336453455837893284460576050862858626214885863
 
# recovered set of primes
pr = [31543315673157331607316493166731687316993135731379313973146931477315133151731531]
hp = FAFTER % p 
hq = FAFTER % q 
 
# reverse
for i in pr:
    hp = REV(hp, i, p)
    hq = REV(hq, i, q)
 
flag, tt = CRT(hp, p, hq, q) # CRT
print(long_to_bytes(flag))
cs

'CTF' 카테고리의 다른 글

LINE CTF Crypto Writeups  (0) 2021.03.22
zer0pts CTF 2021 Crypto Writeups  (1) 2021.03.07
UnionCTF Crypto Writeups  (2) 2021.02.21
CryptoHack All Solve, Again  (10) 2021.01.31
0x41 CTF SPN Writeup  (0) 2021.01.31

I participated in UnionCTF as a member of Super Guesser. We reached 1st place :)

 

Mordell Primes

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
from Crypto.Util.number import bytes_to_long
from secrets import k, FLAG
 
assert k < 2^128
assert FLAG.startswith(b'union{')
 
= EllipticCurve(QQ,[0,1,0,78,-16])
= E(1,8)
= k*P
= (k+1)*P
 
= Q[0].numerator()
= R[0].numerator()
 
assert is_prime(p)
assert is_prime(q)
 
= 0x10001
= p*q
= bytes_to_long(FLAG)
= pow(m,e,N)
 
print(f'N = {N}')
print(f'c = {c}')
 
cs

Solution

The main intuition is that the numerator of $kP$ will grow very fast as $k$ increases. This can be verified by simply checking for small $k$.

Therefore, we can try to simply brute force the value of $k$ and see if the numerators multiply to $N$.

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
= EllipticCurve(QQ,[0,1,0,78,-16])
= E(1,8)
 
for i in range(240):
    Q = i * P
    cc = Q[0].numerator()
    if N % cc == 0:
        print(cc) # p, q
 
= cc
= N / cc
= 65537
phi = (p-1* (q-1)
= inverse(e, phi)
print(long_to_bytes(pow(c, d, N)))
cs

 

Human Server

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
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
import os, random, hashlib, textwrap, json
from Crypto.Cipher import AES
from Crypto.Util.Padding import pad, unpad
from Crypto.Util.number import getPrime, long_to_bytes
 
 
from fastecdsa.curve import secp256k1
from fastecdsa.point import Point
 
FLAG = b'union{XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX}'
 
CURVE = secp256k1
ORDER = CURVE.q
= CURVE.G
 
class EllipticCurveKeyExchange():
    def __init__(self):
        self.private = random.randint(0,ORDER)
        self.public = self.get_public_key()
        self.recieved = None
        self.nonce = None
        self.key = None
 
    def get_public_key(self):
        A = G * self.private
        return A
 
    def send_public(self):
        return print(json.dumps({"Px" : self.public.x, "Py" : self.public.y}))
 
    def receive_public(self, data):
        """
        Remember to include the nonce for ultra-secure key exchange!
        """
        Px = int(data["Px"])
        Py = int(data["Py"])
        self.recieved = Point(Px, Py, curve=secp256k1)
        self.nonce = int(data['nonce'])
 
    def get_shared_secret(self):
        """
        Generates the ultra secure secret with added nonce randomness
        """
        assert self.nonce.bit_length() > 64
        self.key = (self.recieved * self.private).x ^ self.nonce
 
    def check_fingerprint(self, h2: str):
        """
        If this is failing, remember that you must send the SAME
        nonce to both Alice and Bob for the shared secret to match
        """
        h1 = hashlib.sha256(long_to_bytes(self.key)).hexdigest()
        return h1 == h2
 
    def send_fingerprint(self):
        return hashlib.sha256(long_to_bytes(self.key)).hexdigest()
 
def print_header(title: str):
    print('\n\n'+'*'*64+'\n'+'*'+title.center(62)+'*\n'+'*'*64+'\n\n')
 
def input_json(prompt: str):
    data = input(prompt)
    try:
        return json.loads(data)
    except:
        print({"error""Input must be sent as a JSON object"})
        exit()
 
def encrypt_flag(shared_secret: int):
    iv = os.urandom(16)
    key = hashlib.sha1(long_to_bytes(shared_secret)).digest()[:16]
    cipher = AES.new(key, AES.MODE_CBC, iv)
    ciphertext = cipher.encrypt(pad(FLAG, 16))
 
    data = {}
    data['iv'= iv.hex()
    data['encrypted_flag'= ciphertext.hex()
    return print(json.dumps(data))
 
 
Alice = EllipticCurveKeyExchange()
Bob = EllipticCurveKeyExchange()
 
print_header('Welcome!'
message = "Hello! Thanks so much for jumping in to help. Ever since everyone left WhatsApp, we've had a hard time keeping up with communications. We're hoping by outsourcing the message exchange to some CTF players we'll keep the load down on our servers... All messages are end-to-end encrypted so there's no privacy issues at all, we've even rolling out our new ultra-secure key exchange with enhanced randomness! Again, we really appreciate the help, feel free to add this experience to your CV!"
welcome = textwrap.fill(message, width=64)          
print(welcome)
 
print_header('Alice sends public key')
Alice.send_public()
 
print_header("Please forward Alice's key to Bob")
alice_to_bob = input_json('Send to Bob: ')
Bob.receive_public(alice_to_bob)
 
print_header('Bob sends public key')
Bob.send_public()
 
print_header("Please forward Bob's key to Alice")
bob_to_alice = input_json('Send to Alice: ')
Alice.receive_public(bob_to_alice)
            
Alice.get_shared_secret()
Bob.get_shared_secret()
 
print_header('Key verification in progress')
alice_happy = Alice.check_fingerprint(Bob.send_fingerprint())
bob_happy = Bob.check_fingerprint(Alice.send_fingerprint())
if not alice_happy or not bob_happy:
    print({"error""Alice and Bob panicked: Potential MITM attack in progress!!"})
    exit()
 
print_header('Alice sends encrypted flag to Bob')
encrypt_flag(Alice.key)
 
 
cs

Solution

We have to do a MITM attack, but there are some twists - Alice and Bob do a check later to see if they have the same shared secret.

Suppose Alice's secret key, public key is $d_1$, $d_1G$ and Bob's secret key, public key is $d_2$, $d_2G$.

 

Now let's forward Alice's key as $e_1G$ and give the nonce $r_1$ to Bob, where we know the value $e_1$.

This will make Bob's shared secret equal to $(e_1d_2G).x \oplus r_1$. Since $d_2G$ is a known value and $e_1, r_1$ are known, we can calculate this.

 

Similarly, let's forward Bob's key as $e_2G$ and give the nonce $r_2$ to Alice, where we know the value $e_2$.

This will make Alice's shared secret equal to $(e_2d_1G).x \oplus r_2$. We have to make this equal to Bob's shared secret. 

 

This can be done by selecting $r_2$ as $$r_2 = (e_1d_2G).x \oplus (e_2d_1G).x \oplus r_1$$ and we can pass the check between Alice and Bob. We now know the shared secret, so the challenge is solved.

 

Of course, we can just choose $e_1 = e_2 = 1$ and some random value for $r_1$.

 

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
70
71
# curve parameter
= 0x79BE667EF9DCBBAC55A06295CE870B07029BFCDB2DCE28D959F2815B16F81798
= 0x483ADA7726A3C4655DA4FBFC0E1108A8FD17B448A68554199C47D08FFB10D4B8
# random nonce
nonce = 0x7918768917649876918679818976981769817691968917698769
 
= remote('134.122.111.232'54321)
r.recvuntil("Alice sends public key")
r.recvline()
r.recvline()
r.recvline()
 
AliceKey = json.loads(r.recvline())
AX = AliceKey["Px"]
AY = AliceKey["Py"]
 
r.recvuntil("Please forward Alice's key to Bob")
r.recvline()
r.recvline()
r.recvline()
 
data = {
    "Px" : X,
    "Py" : Y,
    "nonce" : nonce
}
 
r.send(json.dumps(data))
 
r.recvuntil("Bob sends public key")
r.recvline()
r.recvline()
r.recvline()
 
BobKey = json.loads(r.recvline())
BX = BobKey["Px"]
BY = BobKey["Py"]
 
r.recvuntil("Please forward Bob's key to Alice")
r.recvline()
r.recvline()
r.recvline()
 
 
nonce2 = nonce ^ AX ^ BX
 
data = {
    "Px" : X,
    "Py" : Y,
    "nonce" : nonce2
}
 
r.send(json.dumps(data))
 
shared_secret = BX ^ nonce
 
r.recvuntil("Alice sends encrypted flag to Bob")
r.recvline()
r.recvline()
r.recvline()
 
fin = json.loads(r.recvline())
 
iv = bytes.fromhex(fin["iv"])
enc = bytes.fromhex(fin["encrypted_flag"])
 
key = hashlib.sha1(long_to_bytes(shared_secret)).digest()[:16]
cipher = AES.new(key, AES.MODE_CBC, iv)
flag = cipher.decrypt(enc)
 
print(flag)
cs

 

Cr0wn St3rling

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
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
#!/usr/bin/env python3
 
from secrets import flag, musical_key
from Crypto.Util.number import isPrime
import math
 
 
def sieve_for_primes_to(n):
    # Copyright Eratosthenes, 204 BC
    size = n//2
    sieve = [1]*size
    limit = int(n**0.5)
    for i in range(1, limit):
        if sieve[i]:
            val = 2*i+1
            tmp = ((size-1- i)//val
            sieve[i+val::val] = [0]*tmp
    return [2+ [i*2+1 for i, v in enumerate(sieve) if v and i > 0]
 
 
def is_quasi_prime(n, primes):
    # novel class of semi-prime numbers
    # https://arxiv.org/pdf/1903.08570.pdf
    p2 = 0
    for p1 in primes:
        if n % p1 == 0:
            p2 = n//p1
            break
    if isPrime(p2) and not p1 in [23and not p2 in [23]:
        return True
    return False
 
 
def bbp_pi(n):
    # Bailey-Borwein-Plouffe Formula
    # sounds almost as cool as Blum-Blum-Shub
    # nth hex digit of pi
    def S(j, n):
        s = 0.0
        k = 0
        while k <= n:
            r = 8*k+j
            s = (s + pow(16, n-k, r) / r) % 1.0
            k += 1
        t = 0.0
        k = n + 1
        while 1:
            newt = t + pow(16, n-k) / (8*k+j)
            if t == newt:
                break
            else:
                t = newt
            k += 1
        return s + t
 
    n -= 1
    x = (4*S(1, n) - 2*S(4, n) - S(5, n) - S(6, n)) % 1.0
    return "%02x" % int(x * 16**2)
 
 
def digital_root(n):
    # reveals Icositetragon modalities when applied to Fibonacci sequence
    return (n - 1) % 9 + 1 if n else 0
 
 
def fibonacci(n):
    # Nature's divine proportion gives high-speed oscillations of infinite
    # wave values of irrational numbers
    assert(n >= 0)
    if n < digital_root(2):
        return n
    else:
        return fibonacci(n - 1+ fibonacci(n - 2)
 
 
def is_valid_music(music):
    # Leverage music's infinite variability
    assert(all(c in "ABCDEFG" for c in music))
 
 
def is_valid_number(D):
    # Checks if input symbolizes the digital root of oxygen
    assert(8==D)
 
 
def get_key(motif):
    is_valid_music(motif)
    is_valid_number(len(motif))
    # transpose music onto transcendental frequencies
    indexes = [(ord(c)-0x40)**for i, c in enumerate(motif)]
    size = sum(indexes)
    assert(size < 75000# we will go larger when we have quantum
    return indexes, size
 
 
def get_q_grid(size):
    return [i for i in range(size) if is_quasi_prime(i, sieve_for_primes_to(math.floor(math.sqrt(size))+1))]
 
 
if __name__ == "__main__":
    print("[+] Oscillating the key")
    key_indexes, size = get_key(musical_key)
    print("[+] Generating quasi-prime grid")
    q_grid = get_q_grid(size)
    # print(f"indexes: {key_indexes}  size: {size}  len(q_grid): {len(q_grid)}")
 
    out = []
    for i, p in enumerate(flag):
        print(f"[+] Entangling key and plaintext at position {i}")
        index = key_indexes[i % len(key_indexes)] * fibonacci(i)
        q = q_grid[index % len(q_grid)]
        key_byte_hex = bbp_pi(q)
        # print(f"index: {index:10}  fib: {fibonacci(i):10}  q-prime: {q:10}  keybyte: {key_byte_hex:10}")
        out.append(ord(p) ^ int(key_byte_hex, 16))
 
    print(f"[+] Encrypted: {bytes(out).hex()}")
 
cs

Solution

We start by making some quick observations -

  • The length of key_indexes must be 8 due to the check is_valid_number.
  • There are only $7^8$ keys since there are 7 possible choices for each entry of the musical key.
  • Actually, the first entry of key_indexes is guaranteed to be 1. Make that $7^7$ valid key_indexes.
  • Fibonacci function is implemented pretty badly, and we can change that to simple dynamic programming
  • There's nothing we need to do about the $\pi$ function
  • The q_grid function is suboptimal, but it runs in decent time so no need to worry about that.

We will now decrease the number of key_index candidates by using the known plaintext "union".

  • Each entry of key_indexes works in a separable way, so we can work with each entry separately
  • There are 7 possible choices for a specific key_indexes entry
  • The problem is we do not know the length of q_grid (we do not know the variable $size$ in advance)
  • For $i < 5$, the maximum value of the index is $7^4 \cdot 3$, which is small compared to the length of the q_grid for $size = 75000$.
  • Therefore, we can reasonably assume that the length of q_grid is larger than the index.
  • Now we can try out each candidate for a specific key_indexes entry - this is sufficient to get the first 5 entries of key_indexes.

We now have only $7^3$ candidates to work with. We can try every single one of them.

  • The only slow part of the brute force is generating the q_grid and calculating its length.
  • This can be done very fast by calculating q_grid for $size = 75000$ and using binary search to calculate the "actual length of the q_grid" (i.e. number of entries that are less than the actual "size" variable)
  • However, since we have a lot of time, we can just calculate q_grid for $size = 75000$ then simply calculate the actual length of the q_grid with brute force. It doesn't take too long for us to get the flag anyway..
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
q_grid = get_q_grid(75005
enc = bytes.fromhex('76f64667220717784affa07cf6b8be52c7d8348d778a41615efa9e53f2566b27fd96eb984c08')
fib = [01]
for i in range(250):
    fib.append(fib[i-1+ fib[i-2])
 
# the first note of the musical key does not matter since the key_index will become 1 anyway
 
# known plaintext
ptxt = "union"
for i in range(05):
    if i == 0# first note doesn't matter
        continue
    for j in range(18): # try all 7 possible notes
        idx = (j ** i) * fib[i]
        q = q_grid[idx] # we assume idx < actual length of q_grid
        key_byte_hex = bbp_pi(q)
        out = enc[i] ^ int(key_byte_hex, 16)
        if out == ord(ptxt[i]): # match
            print(chr(64 + j)) # output the note
 
# results in C D A D -> set the first five notes as A C D A D
 
 
# brute force 7^3
for i in range(07):
    for j in range(07):
        for k in range(07):
            T = ['A''C''D''A''D', chr(65+i), chr(65+j), chr(65+k)]
            key_indexes , size = get_key(T)
            # remove the assertion for size in get_key
            if size >= 75000:
                continue
            # compute the "actual" q_grid
            q_qgrid = []
            for val in q_grid:
                if val < size:
                    q_qgrid.append(val)
            # compute the plaintext
            out = []
            for ii in range(0len(enc)):
                idx = key_indexes[ii % 8* fib[ii]
                q = q_qgrid[idx % len(q_qgrid)]
                key_byte_hex = bbp_pi(q)
                out.append(enc[ii] ^ int(key_byte_hex, 16))
            print(bytes(out)) # wait for it..
cs

 

Neo-Classical Key Exchange

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
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
import os
from hashlib import sha1
from random import randint
from Crypto.Cipher import AES
from Crypto.Util.Padding import pad, unpad
 
FLAG = b"union{XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX}"
 
def list_valid(l):
    x = l // 2
    checked = set([x])
    while x * x != l:
        x = (x + (l // x)) // 2
        if x in checked: return False
        checked.add(x)
    return True
 
def list_iter(n):
    x = n
    y = (x + 1// 2
    while y < x:
        x = y
        y = (x + n // x) // 2
    return x
 
def list_mul(l1,l2,p):
    X, Y = len(l1), len(l2)
    Z = list_iter(X)
    assert X == Y
    assert list_valid(X)
    l3 = []
    for i in range(Z):
        for j in range(Z):
            prod_list = [x*for x,y in zip(l1[Z*i:Z*(i+1)], l2[j::Z])]
            sum_list = sum(prod_list) % p
            l3.append(sum_list)
    return l3
 
def list_exp(l0, e, p):
    exp = bin(e)[3::]
    l = l0
    for i in exp:
        l = list_mul(l,l,p)
        if i == '1':
            l = list_mul(l,l0,p)
    return l
 
def gen_public_key(G,p):
    k = randint(2,p-1)
    B = list_exp(G,k,p)
    return B,k
 
def gen_shared_secret(M,k,p):
    S = list_exp(M,k,p)
    return S[0]
 
def encrypt_flag(shared_secret: int):
    # Derive AES key from shared secret
    key = sha1(str(shared_secret).encode('ascii')).digest()[:16]
    # Encrypt flag
    iv = os.urandom(16)
    cipher = AES.new(key, AES.MODE_CBC, iv)
    ciphertext = cipher.encrypt(pad(FLAG, 16))
    # Prepare data to send
    data = {}
    data['iv'= iv.hex()
    data['encrypted_flag'= ciphertext.hex()
    return data
 
= 64050696188665199345192377656931194086566536936726816377438460361325379667067
= [37474442957545178764106324981526765864975539603703225974060597893616967420393,59548952493843765553320545295586414418025029050337357927081996502641013504519311002066525512164709938000874013049550644788296268367056724529039089424037491386031482454287572407012381137953191558164465623529992046661815621863204773420708638990322428536520731257757756431087939910637434308755686013682215836263249525491465214495369731073552931306211582961157162030422899032923981311376221021836681925641294064263844659958138617889034069800460358141630174638641532727035735045369266322629011656427579578656066165031820538678953220132827396471587929444138998790449514672948945562632375967133202143205396916253265051473730587605323370564700860148975988622662724284710157566957213620913591119857266366110422436201592848917393008725709237548443793017124298122562856326649394385871891424162512325919431373810173511592710316040978823523358078859249102260718794394402264910240234942692440221176187631522440611503354694959423849000390378955527116778192120808910199353602982871650771627510964301491382871751987924260652304319543941114891709993329929124030812683307477971502992612959253926958823705349101783144068766123926443603026261539055007453105405205925131925190516128252182445043410788021004743874404334678085306701603781467743100927869431963764735143299058921864701886615585381428010877330553242342651823130483453772716228097418145796292233111277774478016673520810772503991055566747628629443375207256050745127045919163601367018956550763591458462169205918826786898398213162402878653481728846096771599941966230969939621034765177430371547059243127032356850437797415676110660436413346535063433156355547532408592015995190002391616368774565349584890853755466839699622482020499285870283811476739960099513665661150287720594400034444826365313288645670526357669076978338398633256587,23887025917289715287437926862183042001010845671403682948840305587666551788353]
A,a = gen_public_key(G,p)
B,b = gen_public_key(G,p)
assert gen_shared_secret(A,b,p) == gen_shared_secret(B,a,p)
 
shared_secret = gen_shared_secret(B,a,p)
encrypted_flag = encrypt_flag(shared_secret)
 
print(f"Alice's public key: {A}"
print(f"Bob's public key: {B}")
print(f"Encrypted flag: {encrypted_flag}")
 
 
 
 
cs

Solution

The first thing we notice is that list_valid checks if the length of the list is a square, and list_iter simply returns the square root.

Now we look at the main function, the list multiplication. If we stare at it for a while (and notice the "length is a square" stuff) we can find out that this list multiplication is just a matrix multiplication, where the list is the matrix entries in order.

 

Now we have to solve a Diffie-Hellman problem with matrices over $\mathbb{F}_p$. We will do it by solving discrete logarithm problem.

  • Idea 1 : The range of the secret keys
  • The range of the secret keys are from $2$ to $p$ - this implies that we do not need the entire discrete logarithm, just some part of it.
  • Idea 2 : The determinant
  • This idea can give us the required information of the (matrix) discrete logarithm if we calculate the respective discrete logarithm (over finite field $\mathbb{F}_p$) on the determinants. However, the primes used here are quite large, which makes this approach infeasible.

Instead of $5 \times 5$ matrices that we actually have to deal with, we first look at $2 \times 2$ matrices over the reals.

The idea is that we want to look at how matrix powers behave in a more relaxed setting, and find some properties we can use in $\mathbb{F}_p$.

 

We try to solve $A^k = B$. Take the Jordan Canonical Form $J$ with $A = PJP^{-1}$. Then we want to solve $$J^k = P^{-1}BP$$ so we may simply assume that $A$ is in a Jordan Canonical Form. If $A$ is in the form of $$A = \left[ \begin{matrix} \lambda_1 & 0 \\ 0 & \lambda_2 \end{matrix} \right]$$ then nothing special happens - we still need a logarithm to compute $k$, which is infeasible in $\mathbb{F}_p$. However, if $A$ is in the form of $$A = \left[ \begin{matrix} \lambda & 1 \\ 0 & \lambda \end{matrix} \right]$$ then we have some interesting phenomenon : $$A^k = \left[ \begin{matrix} \lambda^k & k \lambda^{k-1} \\ 0 & \lambda^k \end{matrix} \right]$$ and now we can get $k$ as $(k\lambda^{k-1} \times \lambda) / \lambda^k$, all of which we know and can be done in $\mathbb{F}_p$.

 

Let's come back to $\mathbb{F}_p$. If we want to use the Jordan Canoncial Form trick, we need our characteristic polynomial to completely split in $\mathbb{F}_p$, and we also need a block of size $2$. Our matrix satisfies all of these properties, so we can just use the exact same technique here as well.

 

Of course, we can see that we have calculated the discrete logarithm modulo $p$, not the full discrete logarithm.

However, because of Idea 1 (secret key range is small) this is enough to solve the problem. Here are some additional notes.

  • The Jordan Canonical Form of our matrix $G$ has 4 blocks of size 1, 1, 1, 2. 
  • This actually shows that the order of $G$ is $p(p-1)$ - this can be seen as the $p-1$ term dealing with the diagonal entries (Fermat's Theorem) and the $p$ term dealing with the off-diagonal term in our block of size 2. 
  • If we want to solve for the discrete logarithm modulo $p-1$, we need discrete logarithm over $\mathbb{F}_p$ which we decided infeasible.
  • I think there are some similarities with this challenge and TetCTF 2021 unevaluated.
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
= 64050696188665199345192377656931194086566536936726816377438460361325379667067
 
def MAT(X):
    M = Matrix(GF(p), 55)
    cnt = 0 
    for i in range(05):
        for j in range(05):
            M[i, j] = X[cnt]
            cnt += 1
    return M
 
= 
= 
= 
 
GMAT = MAT(G)
AMAT = MAT(A)
BMAT = MAT(B)
 
print(GMAT.charpoly().factor())
print(AMAT.charpoly().factor())
print(BMAT.charpoly().factor())
 
 
J, P = GMAT.jordan_form(transformation = True)
AMAT = P^-1 * AMAT * P
BMAT = P^-1 * BMAT * P
print(J)
print("")
print(AMAT)
print("")
print(BMAT)
 
print(AMAT[34/ AMAT[33* J[33]) # dlog
print(BMAT[34/ BMAT[33* J[33]) # dlog
# the rest is relatively trivial (get shared secret, decrypt flag)
cs

 

Why is a raven

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
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
import hashlib
from base64 import b64encode
from Crypto.Cipher import AES
 
= 2^216*3^137 - 1
F.<i> = GF(p^2, modulus=x^2+1)
= EllipticCurve(F, [06010])
 
xQ20=0x000C7461738340EFCF09CE388F666EB38F7F3AFD42DC0B664D9F461F31AA2EDC6B4AB71BD42F4D7C058E13F64B237EF7DDD2ABC0DEB0C6C
xQ21=0x00025DE37157F50D75D320DD0682AB4A67E471586FBC2D31AA32E6957FA2B2614C4CD40A1E27283EAAF4272AE517847197432E2D61C85F5
yQ20=0x001D407B70B01E4AEE172EDF491F4EF32144F03F5E054CEF9FDE5A35EFA3642A11817905ED0D4F193F31124264924A5F64EFE14B6EC97E5
yQ21=0x000E7DEC8C32F50A4E735A839DCDB89FE0763A184C525F7B7D0EBC0E84E9D83E9AC53A572A25D19E1464B509D97272AE761657B4765B3D6
xP20=0x0003CCFC5E1F050030363E6920A0F7A4C6C71E63DE63A0E6475AF621995705F7C84500CB2BB61E950E19EAB8661D25C4A50ED279646CB48
xP21=0x001AD1C1CAE7840EDDA6D8A924520F60E573D3B9DFAC6D189941CB22326D284A8816CC4249410FE80D68047D823C97D705246F869E3EA50
yP20=0x001AB066B84949582E3F66688452B9255E72A017C45B148D719D9A63CDB7BE6F48C812E33B68161D5AB3A0A36906F04A6A6957E6F4FB2E0
yP21=0x000FD87F67EA576CE97FF65BF9F4F7688C4C752DCE9F8BD2B36AD66E04249AAF8337C01E6E4E1A844267BA1A1887B433729E1DD90C7DD2F
xQ30=0x0012E84D7652558E694BF84C1FBDAAF99B83B4266C32EC65B10457BCAF94C63EB063681E8B1E7398C0B241C19B9665FDB9E1406DA3D3846
xQ31=0x0000000
yQ30=0x0000000
yQ31=0x000EBAAA6C731271673BEECE467FD5ED9CC29AB564BDED7BDEAA86DD1E0FDDF399EDCC9B49C829EF53C7D7A35C3A0745D73C424FB4A5FD2
xP30=0x0008664865EA7D816F03B31E223C26D406A2C6CD0C3D667466056AAE85895EC37368BFC009DFAFCB3D97E639F65E9E45F46573B0637B7A9
xP31=0x0000000
yP30=0x0006AE515593E73976091978DFBD70BDA0DD6BCAEEBFDD4FB1E748DDD9ED3FDCF679726C67A3B2CC12B39805B32B612E058A4280764443B
yP31=0x0000000
 
Q_A = E(xQ20 + xQ21*i, yQ20 + yQ21*i)
P_A = E(xP20 + xP21*i, yP20 + yP21*i)
Q_B = E(xQ30 + xQ31*i, yQ30 + yQ31*i)
P_B = E(xP30 + xP31*i, yP30 + yP31*i)
 
# Computes an l^e-isogeny out of E from a set Ss of kernel generators
# as a composition of e l-isogenies
def computeIsogeny(E, Ss, l, e):
    S_tmps = Ss
    E_tmp = E
    ϕ = None
    for k in range(e):
        R_tmps = S_tmps
        for _ in range(e-k-1):
            R_tmps = [ l*R_tmp for R_tmp in R_tmps ]
        ϕ_k = E_tmp.isogeny(kernel=R_tmps)
 
        S_tmps = [ ϕ_k(S_tmp) for S_tmp in S_tmps ]
        E_tmp = ϕ_k.codomain()
        if ϕ is None:
            ϕ = ϕ_k
        else:
            ϕ = ϕ_k * ϕ
    return ϕ
 
k_A = randint(02^216-1)
S_A = P_A + k_A*Q_A
ϕ_A = computeIsogeny(E, [S_A], 2216)
Alice = (ϕ_A.codomain().a_invariants(), ϕ_A(P_B).xy(), ϕ_A(Q_B).xy(), ϕ_A(P_A).xy(), ϕ_A(Q_A).xy())
print(f"Alice = {Alice}")
 
k_B = randint(03^137-1
S_B = P_B + k_B*Q_B
ϕ_B = computeIsogeny(E, [S_B], 3137)
Bob = (ϕ_B.codomain().a_invariants(), ϕ_B(P_A).xy(), ϕ_B(Q_A).xy())
print(f"Bob = {Bob}")
 
(E_B, B_P_A, B_Q_A) = Bob
E_B = EllipticCurve(F, E_B)
B_P_A = E_B(B_P_A)
B_Q_A = E_B(B_Q_A)
B_S_A = B_P_A + k_A*B_Q_A
jAlice = computeIsogeny(E_B, [B_S_A], 2216).codomain().j_invariant()
 
(E_A, A_P_B, A_Q_B, _, _) = Alice
E_A = EllipticCurve(F, E_A)
A_P_B = E_A(A_P_B)
A_Q_B = E_A(A_Q_B)
A_S_B = A_P_B + k_B*A_Q_B
jBob = computeIsogeny(E_A, [A_S_B], 3137).codomain().j_invariant()
 
assert jAlice == jBob
 
flag = open("flag.txt""rb").read().strip()
assert len(flag) == 32
 
sk = hashlib.sha256(str(jAlice).encode('ascii')).digest()[:16]
cipher = AES.new(sk, AES.MODE_CBC)
print(f"iv = {b64encode(cipher.iv)}")
print(f"ct = {b64encode(cipher.encrypt(flag))}")
 
cs

 

Solution

This is Supersingular Isogeny Diffie-Hellman protocol. Looking at the Wikipedia article and back, we see that we have extra information, $$\phi_A(P_A), \phi_A(Q_A)$$ Since $\phi_A$ has $S_A = P_A + k_AQ_A$ as it's kernel, we see that $$ \phi_A(P_A + k_AQ_A) = \phi_A(P_A) + k_A \phi_A(Q_A) = 0$$ Also, since the values here are in a subgroup of order $2^{216}$, we can calculate $k_A$ (i.e. discrete logarithm) efficiently with Pohlig-Hellman.

 

Now that we know $k_A$, one of the secret keys, we can just follow our algorithm to calculate the shared secret.

 

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
70
71
72
73
74
75
= 2^216*3^137 - 1
F.<i> = GF(p^2, modulus=x^2+1)
= EllipticCurve(F, [06010])
 
def computeIsogeny(E, Ss, l, e):
    S_tmps = Ss
    E_tmp = E
    ϕ = None
    for k in range(e):
        R_tmps = S_tmps
        for _ in range(e-k-1):
            R_tmps = [ l*R_tmp for R_tmp in R_tmps ]
        ϕ_k = E_tmp.isogeny(kernel=R_tmps)
 
        S_tmps = [ ϕ_k(S_tmp) for S_tmp in S_tmps ]
        E_tmp = ϕ_k.codomain()
        if ϕ is None:
            ϕ = ϕ_k
        else:
            ϕ = ϕ_k * ϕ
    return ϕ
 
xQ20=0x000C7461738340EFCF09CE388F666EB38F7F3AFD42DC0B664D9F461F31AA2EDC6B4AB71BD42F4D7C058E13F64B237EF7DDD2ABC0DEB0C6C
xQ21=0x00025DE37157F50D75D320DD0682AB4A67E471586FBC2D31AA32E6957FA2B2614C4CD40A1E27283EAAF4272AE517847197432E2D61C85F5
yQ20=0x001D407B70B01E4AEE172EDF491F4EF32144F03F5E054CEF9FDE5A35EFA3642A11817905ED0D4F193F31124264924A5F64EFE14B6EC97E5
yQ21=0x000E7DEC8C32F50A4E735A839DCDB89FE0763A184C525F7B7D0EBC0E84E9D83E9AC53A572A25D19E1464B509D97272AE761657B4765B3D6
xP20=0x0003CCFC5E1F050030363E6920A0F7A4C6C71E63DE63A0E6475AF621995705F7C84500CB2BB61E950E19EAB8661D25C4A50ED279646CB48
xP21=0x001AD1C1CAE7840EDDA6D8A924520F60E573D3B9DFAC6D189941CB22326D284A8816CC4249410FE80D68047D823C97D705246F869E3EA50
yP20=0x001AB066B84949582E3F66688452B9255E72A017C45B148D719D9A63CDB7BE6F48C812E33B68161D5AB3A0A36906F04A6A6957E6F4FB2E0
yP21=0x000FD87F67EA576CE97FF65BF9F4F7688C4C752DCE9F8BD2B36AD66E04249AAF8337C01E6E4E1A844267BA1A1887B433729E1DD90C7DD2F
xQ30=0x0012E84D7652558E694BF84C1FBDAAF99B83B4266C32EC65B10457BCAF94C63EB063681E8B1E7398C0B241C19B9665FDB9E1406DA3D3846
xQ31=0x0000000
yQ30=0x0000000
yQ31=0x000EBAAA6C731271673BEECE467FD5ED9CC29AB564BDED7BDEAA86DD1E0FDDF399EDCC9B49C829EF53C7D7A35C3A0745D73C424FB4A5FD2
xP30=0x0008664865EA7D816F03B31E223C26D406A2C6CD0C3D667466056AAE85895EC37368BFC009DFAFCB3D97E639F65E9E45F46573B0637B7A9
xP31=0x0000000
yP30=0x0006AE515593E73976091978DFBD70BDA0DD6BCAEEBFDD4FB1E748DDD9ED3FDCF679726C67A3B2CC12B39805B32B612E058A4280764443B
yP31=0x0000000
 
Q_A = E(xQ20 + xQ21*i, yQ20 + yQ21*i)
P_A = E(xP20 + xP21*i, yP20 + yP21*i)
Q_B = E(xQ30 + xQ31*i, yQ30 + yQ31*i)
P_B = E(xP30 + xP31*i, yP30 + yP31*i)
 
Alice = ((0606001602663961206370988750155271268843249113105575064100544244329723627508642651491029799456469448718421085928092642609765039188242*+ 1712256002728528379082564430864971406334041478918891587209542135490151021806041516369731041317073413033514408943949475062553807198022241966002746626067354539975418232642475646213518284443509300453664841759671344583904727702765870170961054029730219838438930886730*+ 6295409890219768050656401214128285134680958664621604435525993857295024885942522198730338761351461854361939143813589229958450834937), (20693617845250531673791079572257479372406496374051176010221583150895284635664420984163027961195027146723306399733543575074371571546*+ 1257944050482622779039989846116832908011645379538188503193888759983069361986464587598537959460710680527206312828714104047432447257917003339040898697587060167865681315428279965204095022676751761236717662173320135824191474549296911745414760875386583097246892970743*+ 2450227209495560745928392442008559789430024267104386893781343959329588604681368476319376824183170770268590193199446339985032818433), (24196999226902675779045571226331502647086872832197399777068255320010293863359017283213324144431537822661506383681353187559191999771*+ 1403187277399873350729873144059600531393910895713791231342921226797472498403919424333885862617451889202534903916737871843637458172210067956801857468023514754660550671095579147019588599811273848235704360993890497575424172688000039308192770149207724142524545451074*+ 9704956586624341710808951764565861341114293792082683257320510639095567055930904001585984811139137392398321200917960531515122425604), (21482597851178884503353076937735588452957177768806776910812379517549572253101759233236102347370343956671258496673645283509995572850*+ 1409607890280707835592859895681613004561924579015938475117693274564675323421118094150575882783331409169038834693561947366544280925913679392986650554551011681934303695650088628896811869083453967966676089303335417699532232752393700725181942165609165070072195990421*+ 22303973329492411565669001646989446651767650420180177485066511378012378529261175557912535448570067170663048114739927772127080694786), (5031508630808576087782892353323275460174142373365249589846782383660521445945988018058115279743582819518492860550820586178080959929*+ 203618640430880223098324249541882883341295202367378909200012993621765252931980356906286991355846680733796871308320906361027504960035326896702997677262072220524322872052674185107431056651996898750306495168544570294686542579294013185895403025686718275379409582021*+ 7018087072590614715963128743406699936749123349867893045580774389322712378049822434865393438816413618294542843639485193888664986503))
Bob = ((0602510377837984569005668272963938229152759212776314258952545654072169410901298850712372306096701112903052487282410712205434980682770*+ 153361659901846951954864105534225425250790425873535004318638204601924663903808975912970767591961237416790729864700484204884248322513335813103700469160284801960413086144549776993448017319107340684719947118153850729369660724596130930055245047262733708054423015655*+ 17338799868192494405204548102094725343306200019012693785648257092061088427620739800739315465276207804594142456129224673452195357714), (2771195673566835722887342815479686444463738278075014443830431110033775598812266459191044593910730473384513927831673567602258951977*+ 1469564256406938038163605778762348165866304542092994743998859506798641754551769151744125414548886984617946375481700338419227462646318564301293503451778592169644157610474379393936432543000343986957900909392616771402521075243703340903344745798060095728893961976940*+ 19628333731314344646926186187873836263784148023213378217245128148326949516664862760385029489345376891188072162779569669305066964933), (22650214990480384681860370457554162699319780968761610136003430194938807060051700030135993836240770510069117319449743388225281444184*+ 337715599698804126803907287393594153129537868872259108204032694805267651900616891555563288402498128590848003390275824058761569305417806681788782120983952163360625445316482360798557639190977860032884873427321883793075291472918577432082376117267831746467121303568*+ 21533180999838449404329422084189008931697041812999894837076670480949795196804840300494281304611360768987802541355649881398701758313))
 
(E_A, A_P_B, A_Q_B, A_P_A, A_Q_A) = Alice
E_A = EllipticCurve(F, E_A)
A_P_B = E_A(A_P_B)
A_Q_B = E_A(A_Q_B)
A_P_A = E_A(A_P_A)
A_Q_A = E_A(A_Q_A)
 
# print(discrete_log(A_P_A, A_Q_A, ord=2^216, operation='+'))
k_A = 2^216 - 77755655179930472801709066068873804442103726663917450704829188611
(E_B, B_P_A, B_Q_A) = Bob
E_B = EllipticCurve(F, E_B)
B_P_A = E_B(B_P_A)
B_Q_A = E_B(B_Q_A)
B_S_A = B_P_A + k_A*B_Q_A
jAlice = computeIsogeny(E_B, [B_S_A], 2216).codomain().j_invariant()
 
print(jAlice)
 
jAlice = "11056265280320404779614673772059927218754555738553445716393833134024824685344573644810486464601209854921719950024169587404070224902*i + 13483412480684215684949244771895365050495321597976589093887140592592966008044279045153836163302049958922591278477269370463266951423"
sk = hashlib.sha256(jAlice.encode('ascii')).digest()[:16]
 
iv = b'XSglnu+2ZwFuHGE8ddIuJQ=='
iv = base64.b64decode(iv)
ct = b'4VR9ty+lFW6oQoWTVHiDE7A9uKw0KbQzpnCWOGVQXGo='
ct = base64.b64decode(ct)
 
cipher = AES.new(sk, AES.MODE_CBC, iv)
print(cipher.decrypt(ct))
cs

 

'CTF' 카테고리의 다른 글

zer0pts CTF 2021 Crypto Writeups  (1) 2021.03.07
AeroCTF 2021 Crypto Writeups  (0) 2021.02.28
CryptoHack All Solve, Again  (10) 2021.01.31
0x41 CTF SPN Writeup  (0) 2021.01.31
TetCTF 2021 Crypto Writeups  (1) 2021.01.03

이번엔 2등으로 끝 ㅅㅅㅅㅅ

'CTF' 카테고리의 다른 글

AeroCTF 2021 Crypto Writeups  (0) 2021.02.28
UnionCTF Crypto Writeups  (2) 2021.02.21
0x41 CTF SPN Writeup  (0) 2021.01.31
TetCTF 2021 Crypto Writeups  (1) 2021.01.03
PBCTF 2020 Crypto Writeups  (1) 2020.12.07

I participated in 0x41 CTF as a member of Super Guesser, and we reached 2nd place.

 

This is a very rushed writeup, hope to patch it up after I take a sleep...

 

 

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
 
def key_expansion(self, key):
        keys = [None* 5
        keys[0= key[0:4+ key[8:12]
        keys[1= key[4:8+ key[12:16]
        keys[2= key[0:4+ key[8:12]
        keys[3= key[4:8+ key[12:16]
        keys[4= key[0:4+ key[8:12]
        return keys
 
def apply_sbox(self, pt):
    ct = b''
    for byte in pt:
        ct += bytes([sbox[byte]])
    return ct
 
def apply_perm(self, pt):
    pt = bin(int.from_bytes(pt, 'big'))[2:].zfill(64)
    ct = [None* 64
    for i, c in enumerate(pt):
        ct[perm[i]] = c
    return bytes([int(''.join(ct[i : i + 8]), 2for i in range(0len(ct), 8)])
 
def apply_key(self, pt, key):
    ct = b''
    for a, b in zip(pt, key):
        ct += bytes([a ^ b])
    return ct
 
def handle(self):
    keys = self.key_expansion(key)
    for i in range(65536):
        pt = os.urandom(8)
        ct = pt
        ct = self.apply_key(ct, keys[0])
        for i in range(ROUNDS):
            ct = self.apply_sbox(ct)
            ct = self.apply_perm(ct)
            ct = self.apply_key(ct, keys[i+1])
        self.send(str((int.from_bytes(pt, 'big'), int.from_bytes(ct, 'big'))))
cs

 

Summary of the Challenge

Basically, we are given a 4-round SPN cipher, and we have to completely find the key.

To do this, we are given $2^{16}$ known plaintext/ciphertext pairs. We also know the sbox/pbox.

 

The Attack Idea, and the SBOX

Since this is a known plaintext/ciphertext attack, we can think about linear cryptanalysis.

To do that, we have to exploit the SBOX's linear properties. After some researching on past linear cryptanalysis challenges in CTFs, I ended up at this writeup of zer0SPN by NGG. I ran the first code block to find that the SBOX was horrible beyond belief. Denoting $\text{bit}(n, k)$ for $0 \le n < 2^8$ as the $k$th bit from the left when we write $n$ as a $8$-digit binary integer, we can see that $\text{bit}(n, k)$ and $\text{bit}(sbox[n], k)$ are heavily correlated. This is a very simple output compared to the zer0SPN challenge, where several bits come into play at once.

This is a very promising outcome, and shows we are in the right direction. I actually thought I was done at this point :P

 

Attack 1 : Recovering Linear Equations of the Key Bits

Now what we can do is view the SBOX as a simple XOR. 

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
def BIT(v, k):
    cc = bin(v)[2:].zfill(8)
    return ord(cc[k]) - ord('0')
 
invert = []
 
for i in range(08):
    cnt_0, cnt_1 = 00
    for j in range(0256):
        if BIT(j, i) == BIT(sbox[j], i):
            cnt_0 += 1
        else:
            cnt_1 += 1
    if cnt_0 > cnt_1:
        invert.append(0)
    else:
        invert.append(1)
cs

 

Basically, with the above code, we can regard the SBOX as

  • if $invert[i] = 0$, the SBOX does nothing to the $i$th bit
  • if $invert[i]=1$, the SBOX flips the $i$th bit

If we use this idea, then literally everything we do is just permuting and XOR'ing each bit.

Of course, the SBOX is not "perfectly" an XOR, but it will be biased towards it due to the properties we found.

This gives us a method to find 64 linear equations of the key bits in $\mathbb{F}_2$. Here's how we do it.

 

Let's think about the very first (0th) bit of the plaintext. This bit will

  • get XOR'ed by the first key bit of the first key
  • SBOX'ed (which can be approximated by XOR'ing $invert[0]$)
  • PBOX'ed (moved to a new position, where we know)
  • and repeated like that for four times

note that we know the values we "XORed" in the SBOX, and we know the location of the initial 0th bit.

If the $0$th bit ended up in the $u$th bit after the four PBOX applications, we can see that

  • plaintext's $0$th bit gets XORed to some bits we know and some key bits we don't know and becomes the ciphertext's $u$th bit

Of course, this is all an approximation. The fact that remains true, is that the value of $ptxt[0] \oplus ctxt[u]$ will be biased towards either $0$ or $1$. From this, we can retrieve the XOR of key bits that was applied to the bit that was originally the 0th bit of the plaintext.

Doing the same thing for each of the 64 bits, we get 64 equations. Here's the code for the first part.

 

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
def whi(idx, i): # what key bit am I actually using?
    if idx % 2 == 0:
        if i < 32:
            return i
        else:
            return 32 + i
    else:
        if i < 32:
            return 32 + i
        else:
            return 64 + i
 
for i in range(064):
    loc, add = i, 0
    myenc = []
    myenc.append(whi(0, loc)) # key XOR
    for j in range(15):
        add += invert[loc % 8# sbox
        loc = perm[loc] # pbox
        myenc.append(whi(j % 2, loc)) # key XOR
    myenc.append(add % 2)
    myenc.append(loc)
    arr.append(myenc)
 
= remote('185.172.165.118'4004)
pt, ct = [], []
 
print("[+] Receving Plaintext")
 
for i in range(065536):
    tt = r.recvline()
    tt = tt.split(b" ")
    A = bin(int(tt[0][1:-1].decode()))[2:].zfill(64)
    B = bin(int(tt[1][:-2].decode()))[2:].zfill(64)
    pt.append(A)
    ct.append(B)
 
ZERO, ONE = [], []
 
for i in range(064):
    fin = arr[i][-1# final location
    cnt_0, cnt_1 = 00
    for j in range(065536):
        st = ord(pt[j][i]) - ord('0')
        en = ord(ct[j][fin]) - ord('0')
        if st == (en + arr[i][-2]) % 2# XOR of the key bits is 0
            cnt_0 += 1
        else# XOR of the key bits is 1
            cnt_1 += 1
    print(cnt_0, cnt_1) # check bias
    if cnt_0 > cnt_1:
        ZERO.append(arr[i][:-2]) # sum of these = 0
    else:
        ONE.append(arr[i][:-2]) # sum of these = 1
 
cs

 

Attack 2 : Recovering the First Key XOR'ed

Here, we get the entire first key, giving us 64 more bits of information. This solves the challenge.

We will use the 0th bit of the plaintext as an example again. We will denote the final positions of the initial $i$th bit as $f[i]$. In other words, $f[i]$ is simply the P-box iterated four times. We already know that $ptxt[i]$ and $ctxt[f[i]]$ are correlated.

 

However, if we knew the first byte of the first key getting XOR'ed, we can get even better correlation.

In this case, we know how the first byte of the plaintext is going to behave for the first round of encryption - 

  • we know the key being XOR'ed 
  • we can now directly apply the SBOX without approximating
  • we can directly apply the PBOX without any issue

At this point, the "new" value of the initial $i$th bit will have a better correlation with $ctxt[f[i]]$. 

You can get this intuitively, since we have used "less" approximations, or you may use the piling-up lemma.

 

Therefore, we can now brute force the first byte, do the above computation, and see which byte gives the best correlation.

This can be done for each byte separately, giving our 8 key bytes, or 64 bits. Here's the code.

 

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
for i in range(08): # ith byte
    print("[+] Guessing key", i)
    ideals = []
    for j in tqdm(range(0256)): # bruteforce
        cnt_ideal = 0
        for idx in range(08):
            cnt_0, cnt_1 = 00
            for whi in range(065536): # over ptxt/ctxt pairs
                fin_loc = arr[8 * i + idx][-1]
                addv = arr[8 * i + idx][-2- invert[idx]
                bt = BIT(sbox[int(pt[whi][8 * i : 8 * i + 8], 2) ^ j], idx) # the first round
                res = ord(ct[whi][fin_loc]) - ord('0')
                if bt == res:
                    cnt_0 += 1
                else:
                    cnt_1 += 1
            cnt_ideal += max(cnt_0, cnt_1) # the correlation
        ideals.append(cnt_ideal)
    mx = 0
    for j in range(0256): # max correlation
        mx = max(mx, ideals[j])
    print(ideals.index(mx))
cs

 

Finishing Touches

We can embedd every information we gathered as a system of linear equations over $\mathbb{F}_2$.

Solving this with SageMath, we see that there are $16$ solutions for the system. We try all and see which matches the ptxt/ctxt pair.

'CTF' 카테고리의 다른 글

UnionCTF Crypto Writeups  (2) 2021.02.21
CryptoHack All Solve, Again  (10) 2021.01.31
TetCTF 2021 Crypto Writeups  (1) 2021.01.03
PBCTF 2020 Crypto Writeups  (1) 2020.12.07
N1CTF 2020 Crypto Write-Ups  (0) 2020.10.18

I participated in TetCTF as a member of Super Guesser. 

I was first to [solve all three crypto challenges]. Our team finished 2nd place :)


unimplemented (1st solve)

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
70
71
72
73
74
75
76
77
78
79
80
81
from collections import namedtuple
from Crypto.Util.number import getPrime
import random
 
Complex = namedtuple("Complex", ["re""im"])
 
 
def complex_mult(c1, c2, modulus):
    return Complex(
        (c1.re * c2.re - c1.im * c2.im) % modulus,  # real part
        (c1.re * c2.im + c1.im * c2.re) % modulus,  # image part
    )
 
 
def complex_pow(c, exp, modulus):
    result = Complex(10)
    while exp > 0:
        if exp & 1:
            result = complex_mult(result, c, modulus)
        c = complex_mult(c, c, modulus)
        exp >>= 1
    return result
 
 
def generate_key_pair(nbits):
    while True:
        p = getPrime((nbits + 3// 4)
        q = getPrime((nbits + 3// 4)
        n = (p ** 2* (q ** 2)
        if n.bit_length() == nbits:
            return (p, q), n
 
 
def pad(data, length):
    assert len(data) < length
    pad_length = length - len(data) - 1
    pad_data = bytes(random.choices(range(1256), k=pad_length))
    sep = b'\x00'
    return pad_data + sep + data
 
 
def unpad(data):
    assert b"\x00" in data, "incorrect padding"
    return data.split(b"\x00"1)[1]
 
 
def encrypt(public_key, plaintext):
    n = public_key
    plaintext = pad(plaintext, 2 * ((n.bit_length() - 1// 8))
    m = Complex(
        int.from_bytes(plaintext[:len(plaintext) // 2], "big"),
        int.from_bytes(plaintext[len(plaintext) // 2:], "big")
    )
    c = complex_pow(m, 65537, n)
    return (c.re.to_bytes((n.bit_length() + 7// 8"big")
            + c.im.to_bytes((n.bit_length() + 7// 8"big"))
 
 
def decrypt(private_key, ciphertext):
    # TODO
    raise Exception("unimplemented")
 
 
def main():
    private_key, public_key = generate_key_pair(2021)
    from secret import flag
 
    print("private_key =", private_key)
    print("public_key =", public_key)
    print("ciphertext =", encrypt(public_key, flag))
 
 
if __name__ == '__main__':
    main()
 
# Output:
# private_key = (128329415694646850105527417663220454989310213490980740842294900866469518550360977403743209328130516433033852724185671092403884337579882897537139175073013,
#                119773890850600188123646882522766760423725010264224559311769920026142724028924588464361802945459257237815241227422748585976629359167921441645714382651911)
# public_key = 236252683050532196983825794701514768601125614979892312308283919527619033977486749228418695923608569040825653688303374445536392159719426640289893369552258923597180869981053519695297428186215135878525530974780390951763007339139013157234202093279764459949020588291928614938201565110828675907781512603972957429701280916745719458738970910789383870206038035515777549907045905872280803964436193687072794878040018900969772972761081589529671158140590712503582004892067155769362463889653489918914397872964087471457070748108694165412065471040954221707557816986272311750297566993468288899523479556822418109112211944932649
# ciphertext = b'\x00h\xbe\x94\x8c\xcd\xdd\x04\x80\xf4\x9d\t\xd8\x8dO\x08\xf1\xd1\xc4\xb9\xa06\xe7\xe3\xb6\xc3\x01+\xa9\xf2\xb9\xe8\x8d\xe6\xc9\x0c_#\x93\x11\xad\x0f\x90\xd3\x0b6\xb0n\x13\xea~"V6\xdbA&\x87\xfe\xa3C\xcb\x16\xae\xd9\x83\xdbU\xc6\x06\xcd\x9a\x94\xa9\xce\x15{d\x95s\xc2\xfb\x90q\xe7\x02\xa2\x081:_C\xc68\x00y\x7fj4@\xd2\xcdE\x06\x943\xbe\xbcC3\xca\x91\xb4\x0e}C\xab\xff?X\xc30u\x069:Dc\xb5\xdc\x9b0%\x98\xbd\xd9\x13\xc0\x02w\xc5\xe5:\xca\xcf\x0c\xab\xc2\x9b}\xab\xd0\xcc\xbc\x0f\x9e9\t\xf7M\xb3\xed\x86\xb5E\x8b\xbc4\xfaH\x9b4\x1c\xc4\xab\xc0\xaf\x8a5XcX\x19K\xed\x19\xe1\x1c\xd0\x1e\x97c\x9fF:L\x9d\x90p\x99\xb8\xde\xcblM\xb3\x80sSL\xe1\xa4\xd6,\x81\xd6\x9c\xf1\xbb\x9c)\xf03\x155\xc0\xd5v\x13\xd6#\xb7\x19\xdea%\xce<\x1c\xf7\xf2!;\xc1\xd7w\xd1\xc8\x8d/\xaew\xa1\x1d\xc5(\xc8\x9d\x82v\xf6j\x90A,e\xbd\xa7]\x10\x8f\xe5\xe7\x93}:\xdf1~\xec\xd0-o`\r\x96\xe4\x03\xb9E\x9fdF\xc3\xf8L\xa0\xda\xf0E[\xf7\x02\xef|*\x08\x8a5pI&\xa9i\x0fZ\xa8\xb3H\xed\xe8v\xc4H\xff\xdb\xcb\x00\xf1\xae\x9bO\x18\xd5\xd8&p\xf5\xf6\xe9\xf1\x1brs\xc2\r\xceZ\xd0\xb24\x97+\x98b\x0e\xbb\xb8.\x8dT\xe4"\xad\xe4\xa3f\xd0M\xbf\xafX\xbb"[\x99\xdap\xa5\xcfT2Wx\x87M\x7f\x99!>B[Q\x04\xf6\x03\xbc\x84\xf4\xdfj\xdd1^I\x1a\x05\x81\x91\xde9Mf*\x8e\x8d\xe64\xf8\x93\x99&yP\xcd\x00!\x82\xab\xbcy\xed\xf1\x13\xd3\x81\xeaz\xbbP>\x9a2\x8c\x08\x0es\xbc\xa9\xf6\xa3\x8c\xb0\xb9t\xd9?\x06@\xc9\x90\xb7\xa7<\x85\xeb\x1a\x88#\x1c\xc3 \xec\xc7\x94d\x99\xd6\x8e>\x06\xf8Y\xf4\x19\xcaI/hy\x18\x8e\x0e8\xf8\r\xbb\xf6\x11\xb9\x8dCWB6 '
 
cs


Introduction

We are given all the key values and the ciphertext, so we just need to work on the decryption algorithm.

Our encryption is a very RSA-like algorithm, with multiplication defined as complex multiplication, with the Re/Im values taken modulo $n$.


Strategy

Let's think about how RSA decryption works first, and start from there. Denote $n=pq$ with $p, q$ primes.

We know that $\text{gcd}(a, p) = 1$ implies $a^{p-1} \equiv 1 \pmod{p}$. We know that $\text{gcd}(a, q) = 1$ implies $a^{q-1} \equiv 1 \pmod{q}$.

Therefore, we see that with $\text{gcd}(a, p)=1$ and $\text{gcd}(a, q)=1$, we have $a^{(p-1)(q-1)} \equiv 1 \pmod{pq}$.

This is because $a^{(p-1)(q-1)}$ must be $1$ modulo each $p, q$, so we may apply Chinese Remainder Theorem.

Now, with the encryption exponent $e$, we can get decryption exponent by taking modulo inverse mod $(p-1)(q-1)$.


This problem should not be so different. We'll denote $a \equiv b \pmod{n}$ if $a, b$ has same Re/Im values $\pmod{n}$.

We should find a function $f$ that guarantees $a^{f(p)} \equiv 1 \pmod{p^2}$ if $\text{gcd}(a, p) = 1$, and $a^{f(q)} \equiv 1 \pmod{q^2}$ if $\text{gcd}(a, q) = 1$.

  • Immediate Question : What does $\text{gcd}$ mean in this setting? I'll explain this later.

Anyways, if we find such $f$, we know $a^{f(p)f(q)} \equiv 1 \pmod{p^2q^2}$ must hold for the same reason.

We can then find the modular inverse of $e = 65537$ mod $f(p)f(q)$ and get the decryption exponent.


For now, let's just hope that we have $\text{gcd}(a, p) = \text{gcd}(a, q) = 1$ for our ciphertext. (whatever that gcd means)

Now we have to find what the function $f$ is all about. There are at least 5 ways to do this. (I used Method 2.)


Finish

  • Method 1. We simply read the code of unevaluated.
  • It's easy to see that "unevaluated" challenge gives us some discrete logarithm problem.
  • If we read it more carefully, we see that $g = (re, im)^{24}$ precisely has order $pqr = p(p-1)(p+1)/24$. 
  • Therefore, we can see that $(re, im)$ has an order that divides $p(p-1)(p+1)$.
  • We can now guess that $f(p) = p^3 - p$ should work, and it indeed does. Done :)
  • Method 2. We run some tests for small $p$, and guess our way to find $f$.
  • Let's just fix some small primes $p$ and see when $a^{???} \equiv 1 \pmod{p^2}$. 
  • Let's look at the minimum exponent that satisfies the modulo equation. It can be seen that it is a divisor of $p(p-1)(p+1)$.
  • Therefore, we can now guess that $f(p) = p^3 - p$ should work, and it indeed does. Done :)
  • Method 3. We actually do some mathematics to find our answer.
  • We define $\text{gcd}(a+bi, p)$ as $\text{gcd}(a^2+ b^2, p)$. This is highly motivated by the norm map.
  • We first claim that for odd primes $p$ and $\text{gcd}(a+bi, p)=1$, $(a+bi)^{p^2-1} \equiv 1 \pmod{p}$. 
  • Case 1. $p \equiv 3 \pmod{4}$.
  • This is the easy case, as our complex numbers can be now viewed as elements of $\mathbb{F}_{p^2}$.
  • Since the multiplicative group of this extension field has $p^2-1$ elements, we are now done.
  • Case 2. $p \equiv 1 \pmod{4}$.
  • We prove that $(a+bi)^{p-1} \equiv 1 \pmod{p}$. This is enough to prove our result.
  • Denote $(a+bi)^{p-1} \equiv c + di \pmod{p}$, and note that $(a-bi)^{p-1} \equiv c - di \pmod{p}$ by taking conjugates.
  • Now we take an $x$ such that $x^2 \equiv -1 \pmod{p}$. We can now substitute $i$ as $x$ without an issue.
  • This shows $1 \equiv (a+bx)^{p-1} \equiv c+ dx \pmod{p}$, and $1 \equiv (a-bx)^{p-1} \equiv c - dx \pmod{p}$.
  • Combining these two equations directly gives us $c = 1$, and $d = 0$. Note that $x$ is nonzero.
  • Note that $a+bx$ being nonzero $\pmod{p}$ is equivalent to $\text{gcd}(a^2 + b^2, p) = 1$. 
  • Now we prove the main result : $(a+bi)^{p^3 - p} \equiv 1 \pmod{p^2}$ if $\text{gcd}(a+bi, p) = 1$.
  • We know that $(a+bi)^{p^2-1} \equiv 1 \pmod{p}$, so we can write $(a+bi)^{p^2-1} \equiv 1 + pc \pmod{p^2}$ for some complex number $c$.
  • Now we directly use Binomial Theorem to get $(a+bi)^{p^3-p} \equiv (1+pc)^p \equiv 1 \pmod{p^2}$. Done.
  • This shows that we can use $f(p) = p^3-p$, with proof. It should work. Done :)
  • Method 4. Google with keywords "Gaussian Integers" and "RSA"
  • This leads to various resources, such as https://www.diva-portal.org/smash/get/diva2:1170568/FULLTEXT01.pdf
  • Method 5. Lagrange's Theorem
  • We start by defining $\text{gcd}(a+bi, p) = \text{gcd}(a^2 +b^2, p)$, just like in Method 3.
  • It's not hard to prove that the elements with $\text{gcd}(a^2+b^2, p)=1$ form a multiplicative group. 
  • Here, we look at elements $a+bi$ in $\pmod{p^2}$. Therefore, there are at most $p^4$ elements.
  • If we count the number of elements with $\text{gcd}(a^2 + b^2, p)=1$, we can use that as $f(p)$, by Lagrange's Theorem.
  • In other words, we use that $a^{|G|} = e$ for all finite groups $G$, identity element $e$, and element $a \in G$. 
  • Case 1. $p \equiv 3 \pmod{4}$.
  • For $\text{gcd}(a^2+b^2,p) \neq 1$, we need both $a, b$ to be multiples of $p$. There are $p^2$ such elements.
  • Therefore, we may take $f(p) = p^4 - p^2$ in this case. Done!
  • Case 2. $p \equiv 1 \pmod{4}$.
  • We only need to look at $0 \le a, b < p$ and multiply by $p^2$ in the end.
  • If $a=0$, only $b=0$ satisfies $\text{gcd}(a^2+b^2,p) \neq 1$. If $1 \le a < p$, there are $2$ such $b$s. 
  • Therefore, there are $p^2-1-2(p-1) = (p-1)^2$ solutions. Multiplying by $p^2$, we have $f(p) = p^2(p-1)^2$. 
  • Since casework is boring, we just take the LCM of the two expressions and select $f(p) = p^2(p-1)^2(p+1)$. Done.
  • Looking at the flag, the mentions of "counting" seems to imply that this method is the intended solution.

I wish (at least) Method 1 was blocked by allowing contestants to see "unevaluated" only after solving this challenge. :(

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
private_key = (128329415694646850105527417663220454989310213490980740842294900866469518550360977403743209328130516433033852724185671092403884337579882897537139175073013,
               119773890850600188123646882522766760423725010264224559311769920026142724028924588464361802945459257237815241227422748585976629359167921441645714382651911)
public_key = 236252683050532196983825794701514768601125614979892312308283919527619033977486749228418695923608569040825653688303374445536392159719426640289893369552258923597180869981053519695297428186215135878525530974780390951763007339139013157234202093279764459949020588291928614938201565110828675907781512603972957429701280916745719458738970910789383870206038035515777549907045905872280803964436193687072794878040018900969772972761081589529671158140590712503582004892067155769362463889653489918914397872964087471457070748108694165412065471040954221707557816986272311750297566993468288899523479556822418109112211944932649
ciphertext = b'\x00h\xbe\x94\x8c\xcd\xdd\x04\x80\xf4\x9d\t\xd8\x8dO\x08\xf1\xd1\xc4\xb9\xa06\xe7\xe3\xb6\xc3\x01+\xa9\xf2\xb9\xe8\x8d\xe6\xc9\x0c_#\x93\x11\xad\x0f\x90\xd3\x0b6\xb0n\x13\xea~"V6\xdbA&\x87\xfe\xa3C\xcb\x16\xae\xd9\x83\xdbU\xc6\x06\xcd\x9a\x94\xa9\xce\x15{d\x95s\xc2\xfb\x90q\xe7\x02\xa2\x081:_C\xc68\x00y\x7fj4@\xd2\xcdE\x06\x943\xbe\xbcC3\xca\x91\xb4\x0e}C\xab\xff?X\xc30u\x069:Dc\xb5\xdc\x9b0%\x98\xbd\xd9\x13\xc0\x02w\xc5\xe5:\xca\xcf\x0c\xab\xc2\x9b}\xab\xd0\xcc\xbc\x0f\x9e9\t\xf7M\xb3\xed\x86\xb5E\x8b\xbc4\xfaH\x9b4\x1c\xc4\xab\xc0\xaf\x8a5XcX\x19K\xed\x19\xe1\x1c\xd0\x1e\x97c\x9fF:L\x9d\x90p\x99\xb8\xde\xcblM\xb3\x80sSL\xe1\xa4\xd6,\x81\xd6\x9c\xf1\xbb\x9c)\xf03\x155\xc0\xd5v\x13\xd6#\xb7\x19\xdea%\xce<\x1c\xf7\xf2!;\xc1\xd7w\xd1\xc8\x8d/\xaew\xa1\x1d\xc5(\xc8\x9d\x82v\xf6j\x90A,e\xbd\xa7]\x10\x8f\xe5\xe7\x93}:\xdf1~\xec\xd0-o`\r\x96\xe4\x03\xb9E\x9fdF\xc3\xf8L\xa0\xda\xf0E[\xf7\x02\xef|*\x08\x8a5pI&\xa9i\x0fZ\xa8\xb3H\xed\xe8v\xc4H\xff\xdb\xcb\x00\xf1\xae\x9bO\x18\xd5\xd8&p\xf5\xf6\xe9\xf1\x1brs\xc2\r\xceZ\xd0\xb24\x97+\x98b\x0e\xbb\xb8.\x8dT\xe4"\xad\xe4\xa3f\xd0M\xbf\xafX\xbb"[\x99\xdap\xa5\xcfT2Wx\x87M\x7f\x99!>B[Q\x04\xf6\x03\xbc\x84\xf4\xdfj\xdd1^I\x1a\x05\x81\x91\xde9Mf*\x8e\x8d\xe64\xf8\x93\x99&yP\xcd\x00!\x82\xab\xbcy\xed\xf1\x13\xd3\x81\xeaz\xbbP>\x9a2\x8c\x08\x0es\xbc\xa9\xf6\xa3\x8c\xb0\xb9t\xd9?\x06@\xc9\x90\xb7\xa7<\x85\xeb\x1a\x88#\x1c\xc3 \xec\xc7\x94d\x99\xd6\x8e>\x06\xf8Y\xf4\x19\xcaI/hy\x18\x8e\x0e8\xf8\r\xbb\xf6\x11\xb9\x8dCWB6 '
 
= private_key[0]
= private_key[1]
= public_key
= 65537
 
= (p ** 3 - p) * (q ** 3 - q)
= inverse(e, v) 
 
re = bytes_to_long(ciphertext[ : len(ciphertext) // 2])
im = bytes_to_long(ciphertext[len(ciphertext) // 2 : ])
 
ctxt = Complex(re, im)
 
ptxt = complex_pow(ctxt, d, n)
 
print(long_to_bytes(ptxt.re) + long_to_bytes(ptxt.im))
 
# TetCTF{c0unt1ng_1s_n0t_4lw4ys_34sy-vina:*100*48012023578024#}
cs


uncollidable (4th solve)

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
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
import json
import os
from hashlib import sha256
from typing import Callable, Dict
 
H: Callable[[bytes], bytes] = lambda s: sha256(s).digest()
 
 
def get_mac(key: bytes, data: bytes) -> bytes:
    """Just a MAC generation function similar to HMAC.
    Reference link: https://en.wikipedia.org/wiki/HMAC#Implementation
    """
    if len(key) >= 32:
        key = H(key)
 
    if len(key) < 32:
        key += b"\x00" * (32 - len(key))
 
    inner_pad = bytearray(32)
    outer_pad = bytearray(32)
    for i in range(32):
        inner_pad[i] = key[i] ^ 0x20
        outer_pad[i] = key[i] ^ 0x21
 
    return H(outer_pad + H(inner_pad + data))
 
 
class SecureStorage:
    """Changes on data stored in this storage can be detected :) """
 
    def __init__(self, debug=False):
        self.keys: Dict[str, bytes] = {}  # key ID -> key
        self.data: Dict[bytes, bytes] = {}  # MAC -> data
        self.debug = debug
        if debug:
            # when debug mode is on, unique arguments to `store_data` will be
            # logged.
            self.store_data_args = set()
 
    def generate_key(self, key_id: str-> None:
        """Generate a new key."""
        if key_id in self.keys:
            raise Exception("duplicated key id")
        self.keys[key_id] = os.urandom(32)
 
    def import_key(self, key_id: str, key: bytes) -> None:
        """Import an external key."""
        if key_id in self.keys:
            raise Exception("duplicated key id")
        self.keys[key_id] = key
 
    def store_data(self, key_id: str, data: bytes) -> bytes:
        """Store data and return a MAC that can be used to retrieve the data
        later."""
        if key_id not in self.keys:
            raise Exception("key not found")
 
        key = self.keys[key_id]
        if self.debug:
            self.store_data_args.add((key, data))
 
        mac = get_mac(key, data)
        if mac in self.data:
            raise Exception("data already stored")
 
        self.data[mac] = data
        return mac
 
    def retrieve_data(self, key_id: str, mac: bytes) -> bytes:
        """Retrieve data previously stored with `store_data`."""
        if key_id not in self.keys:
            raise Exception("key not found")
        if mac not in self.data:
            raise Exception("data not found")
 
        key = self.keys[key_id]
        data = self.data[mac]
        if get_mac(key, data) != mac:
            raise Exception("data has been tampered")
        return data
 
    def report_bug(self-> int:
        """Claim your bounty here :) """
        if self.debug:
            # check for a massive collision bug
            if len(self.store_data_args) >= 10 and len(self.data) == 1:
                # check if collisions happened through different key sizes
                key_lengths = [len(key) for (key, _) in self.store_data_args]
                if min(key_lengths) < 32 <= max(key_lengths):
                    # Congrats :)
                    from secret import flag
                    return int.from_bytes(flag, "big")
        return 0
 
 
def main():
    ss = SecureStorage(debug=True)
    for _ in range(100):
        try:
            request = json.loads(input())
 
            if request["action"== "generate_key":
                key_id = request["key_id"]
                ss.generate_key(key_id)
                print(json.dumps({}))
 
            if request["action"== "import_key":
                key_id = request["key_id"]
                key = bytes.fromhex(request["key"])
                ss.import_key(key_id, key)
                print(json.dumps({}))
 
            elif request["action"== "store_data":
                key_id = request["key_id"]
                data = bytes.fromhex(request["data"])
                mac = ss.store_data(key_id, data)
                print(json.dumps({"mac": mac.hex()}))
 
            elif request["action"== "retrieve_data":
                key_id = request["key_id"]
                mac = bytes.fromhex(request["mac"])
                data = ss.retrieve_data(key_id, mac)
                print(json.dumps({"data": data.hex()}))
 
            elif request["action"== "report_bug":
                print(json.dumps({"bounty": ss.report_bug()}))
 
        except EOFError:
            break
        except Exception as err:
            print(json.dumps({"error"str(err)}))
 
 
if __name__ == '__main__':
    main()
 
cs


Introduction

We basically need a big collision of the MAC - 10 (key, data) values that map to a single MAC value.

We also need to use at least one key with length less than 32, and at least one key with length no less than 32 as well. 


Strategy & Finish

Let's actually look at the MAC code. We see that some preparation are done to make the key exactly 32 bytes.

If the length is at least 32 bytes, we apply SHA256. If not, we append some zeros at the end to make it 32 bytes.

We immediately get some collision cooking - a key full of zeros will lead to a same MAC if the key length is less than 32 bytes.

Of course, this is easily generalizable - if the "final" 32 byte key ends with a lot of zeros, we get a lot of collisions!


This is extremely suspicious - we just need a key that is at least 32 bytes to go along with it.

This means we need a 32 byte value with SHA256 ending with a lot of zeros - 9 bytes of it, to be exact.


Now this is a problem that I have seen before - DragonCTF's bitflip2. SHA256 ending with a lot of zeros leads us to bitcoin PoW.

We can find a bunch of values with SHA256 ending with 8 bytes worth of zeros in bitcoin. 

Luckily, the value we (Perfect Guesser) used at the time to solve bitflip2 already had 9 bytes of zeros in the end in the SHA256.


This solves the problem. This was a very good review exercise for me :)


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
HOST = "139.162.5.141"
PORT = 5555
 
= remote(HOST, PORT)
 
key_id = ["0""1""2""3""4""5""6""7""8""9"]
 
print(long_to_bytes(61342502866914327869718742967850222962760441300896006743258488289386736694996435440480147718715690291226752007898889408599107845232272253))
tt = '294724a63e0fda8c731d9612b0a8e8b9bc0ec087ca9920c8488c5dd1df94ebff'
tt = bytes.fromhex(tt)
 
data = "1234"
 
for i in range(010):
    key = '294724a63e0fda8c731d9612b0a8e8b9bc0ec087ca9920c8488c5dd1df94ebff'
    if i >= 1:
        key = bytes.fromhex(sha256(bytes.fromhex(key)).hexdigest())
        key = key[:-i]
        key = key.hex()
    request = {
        "action""import_key",
        "key_id": key_id[i],
        "key": key
    }
    r.sendline(json.dumps(request))
    print(r.readline())
    request = {
        "action""store_data",
        "key_id": key_id[i],
        "data": data
    }
    r.sendline(json.dumps(request))
    print(r.readline())
 
request = {
    "action""report_bug"
}
 
r.sendline(json.dumps(request))
 
print(r.readline())
 
# TetCTF{HM4C_c4n_b3_m1sus3d-viettel:*100*718395803842748#}
cs


unevaluated (1st solve)

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
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
from collections import namedtuple
from Crypto.Util.number import getPrime, isPrime, getRandomRange
 
Complex = namedtuple("Complex", ["re""im"])
 
 
def complex_mult(c1, c2, modulus):
    return Complex(
        (c1.re * c2.re - c1.im * c2.im) % modulus,  # real part
        (c1.re * c2.im + c1.im * c2.re) % modulus,  # image part
    )
 
 
def complex_pow(c, exp, modulus):
    result = Complex(10)
    while exp > 0:
        if exp & 1:
            result = complex_mult(result, c, modulus)
        c = complex_mult(c, c, modulus)
        exp >>= 1
    return result
 
 
class ComplexDiffieHellman:
    @staticmethod
    def generate_params(prime_length):
        # Warning: this may take some time :)
        while True:
            p = getPrime(prime_length)
            if p % 4 == 3:
                if p % 3 == 2:
                    q = (p - 1// 2
                    r = (p + 1// 12
                    if isPrime(q) and isPrime(r):
                        break
                else:
                    q = (p - 1// 6
                    r = (p + 1// 4
                    if isPrime(q) and isPrime(r):
                        break
        n = p ** 2
        order = p * q * r
        while True:
            re = getRandomRange(1, n)
            im = getRandomRange(1, n)
            g = complex_pow(Complex(re, im), 24, n)
            if (
                    complex_pow(g, order, n) == Complex(10)
                    and complex_pow(g, order // p, n) != Complex(10)
                    and complex_pow(g, order // q, n) != Complex(10)
                    and complex_pow(g, order // r, n) != Complex(10)
            ):
                return g, order, n
 
    def __init__(self, params=None, prime_length=128, debug=False):
        if not debug:
            raise Exception("security unevaluated")
        if params is None:
            params = ComplexDiffieHellman.generate_params(prime_length)
        self.g, self.order, self.n = params
 
    def get_public_key(self, private_key):
        return complex_pow(self.g, private_key, self.n)
 
    def get_shared_secret(self, private_key, other_public_key):
        return complex_pow(other_public_key, private_key, self.n)
 
 
def main():
    from os import urandom
    private_key = urandom(32)
    k = int.from_bytes(private_key, "big")
 
    cdh = ComplexDiffieHellman(debug=True)
    print("g =", cdh.g)
    print("order =", cdh.order)
    print("n =", cdh.n)
    print("public_key =", cdh.get_public_key(k))
 
    # Solve the discrete logarithm problem if you want the flag :)
    from secret import flag
    from Crypto.Cipher import AES
    if len(flag) % 16 != 0:
        flag += b"\x00" * (16 - len(flag) % 16)
    print("encrypted_flag = ",
          AES.new(private_key, AES.MODE_ECB).encrypt(flag))
 
 
if __name__ == "__main__":
    main()
 
# Output:
# g = Complex(re=20878314020629522511110696411629430299663617500650083274468525283663940214962,
#             im=16739915489749335460111660035712237713219278122190661324570170645550234520364)
# order = 364822540633315669941067187619936391080373745485429146147669403317263780363306505857156064209602926535333071909491
# n = 42481052689091692859661163257336968116308378645346086679008747728668973847769
# public_key = Complex(re=11048898386036746197306883207419421777457078734258168057000593553461884996107,
#                      im=34230477038891719323025391618998268890391645779869016241994899690290519616973)
# encrypted_flag = b'\'{\xda\xec\xe9\xa4\xc1b\x96\x9a\x8b\x92\x85\xb6&p\xe6W\x8axC)\xa7\x0f(N\xa1\x0b\x05\x19@<T>L9!\xb7\x9e3\xbc\x99\xf0\x8f\xb3\xacZ:\xb3\x1c\xb9\xb7;\xc7\x8a:\xb7\x10\xbd\x07"\xad\xc5\x84'
 
cs


Introduction

We are given the same setup from "unimplemented". We are solving $g^x \equiv target \pmod{n}$ given $g, target, n$.

We also know that the order of $g$ is $order = p \cdot q \cdot r = p(p-1)(p+1)/24$, where $p, q, r$ are all primes.

We now have to solve a discrete logarithm problem to get a private key, which is used to encrypt our flag using AES.


Strategy 

First, some basic steps. From $n = p^2$, we can directly calculate the value of $p$.

From the parameter generation process, we can also calculate $q, r$. 

I actually computed $p$ by $\text{gcd}(n, order)$, and factorized $qr = order/ p$ using alpetron. 

UPD: This is why my values of $q, r$ in this writeup and solution code is swapped instead of the original $q, r$ given in the code.

Anyway, we now know $p, q, r$ and can focus on our task, the discrete logarithm.

  • Idea 1. The flaw in parameter generation.
  • There is actually a very important flaw in parameter generation. 
  • Note that $p, q, r$ are around 128 bits. Therefore, the order $pqr$ is around $128 \cdot 3 = 384$ bits.
  • However, the private key used in the AES and the discrete logarithm problem is 32 bytes, i.e. $256$ bits.
  • Pohlig-Hellman style thinking shows we can compute $x \pmod{p}$, $x \pmod{q}$, $x \pmod{r}$ separately. 
  • Because $x$ is $256$ bits, we can simply solve two of these three problems and combine using CRT!!
  • Idea 2. The (multiplicative) norm map $N(a+bi) = a^2 + b^2$. 
  • Note : I proved stuff for "unimplemented" (i.e. Method 3) after I solved all three challenges. However, the proof ideas are used here.
  • The norm map allows us to move from weird complex number stuff to values in $\pmod{p}$, where we are more comfortable.
  • Maybe this idea reminds us of MOV attack? I don't know. Anyways, let's finish the problem.

Finish

Let's first look at $x \pmod{r}$. We note that $r|(p-1)$, so norm map will work nicely here.

  • We want $(g^{pq})^x \equiv target^{pq} \pmod{n}$, so $N(g^{pq})^x \equiv N(target^{pq}) \pmod{p}$. 
  • If we calculate $N(g^{pq})$ and $N(target^{pq})$ we get values that are nonzero, and also not equal to $1$.
  • This is expected to happen, since $pq$ is relatively prime to $p-1$ and $g$ has high order. 
  • Therefore, we can solve the discrete logarithm problem in $\mathbb{F}_p$ to get information on $x$.
  • We also see that (with Sage) $N(g^{pq})$ has order $r$ in $\mathbb{F}_p$. Therefore, we get the entire $x \pmod{r}$ here.

Next, we'll look at $x \pmod{p}$. If we get this, we'll solve the problem.

  • We want $(g^{qr})^x \equiv target^{qr} \pmod{n}$. Note that $24qr \equiv 0 \pmod{p^2-1}$. 
  • Here, the $24$ comes from the fact that $g$ is actually defined as $(re, im)^{24}$ for some $re, im$. 
  • This shows that, from proof in Method 3 above, we must have $g^{qr} \equiv target^{qr} \equiv 1 \pmod{p}$.
  • Therefore, we have $N(g^{qr}) \equiv N(target^{qr}) \equiv 1 \pmod{p}$ as well. 
  • However, computation shows us that $N(g^{qr}) , N(target^{qr})$ are not $1 \pmod{p^2}$. This is not unexpected.
  • We can now solve $x \pmod{p}$ with $N(g^{qr})^x \equiv N(target^{qr}) \pmod{p^2}$. 
  • We simply note that $(1+pa)^x \equiv 1 +pax \pmod{p^2}$ using Binomial Theorem.
  • Write $N(g^{qr}) \equiv 1 +pa \pmod{p^2}$ and $N(target^{qr}) \equiv 1 + pb \pmod{p^2}$, where $a, b$ are nonzero $\pmod{p}$.
  • We now have, simply, $ax \equiv b \pmod{p}$. This can be solved with modular inverse.
  • This looks quite like the Paillier Cryptosystem (which I learned by solving zer0ptsCTF Dirty Laundry) so that's cool.

When I first solved this problem, I found most of these insight by experiments. 

I did think of the norm map first, solved $\pmod{r}$ part based on it, and was quite surprised when $N(g^{qr})$ was $1 \pmod{p}$. 

Then I realized that I could use Binomial Theorem to solve the challenge. Looking back on "unimplemented" during write-up, I found out that most of my "surprises" during the solve was not actually surprising facts :) This is a very nice challenge, so thanks to ndh.


UPD: After some discussion with ndh in the CryptoHack server, it turns out I overcomplicated this problem a bit.

We can just solve for $N(g)^x \equiv N(target) \pmod{p^2}$ and solve the discrete logarithm problem here. :P

UPD: After even more discussion, it turns out that the above discrete logarithm in $\pmod{p^2}$ is not that easy.

Refer to the CryptoHack discord, or the writeup by Mystiz here, and TheBlueFlame121 here for more details. Highly suggest you read them.

These two writeups linked above have some important facts about discrete logarithm calculation in Sage in general. :)

In CryptoHack discord #ctf-challenges, $\$$in has a summarization on this subject, and there are even more discussions with smart people!

UPD: CryptoHack discord #ctf-challenges has a very in-depth analysis and discussion on this challenge, a must read.

UPD: CryptoHack has posted a solid review on these challenges. See https://blog.cryptohack.org/tetctf-2021

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
def norm(x):
    return x.re * x.re + x.im * x.im
 
 
= Complex(re=20878314020629522511110696411629430299663617500650083274468525283663940214962,
            im=16739915489749335460111660035712237713219278122190661324570170645550234520364)
order = 364822540633315669941067187619936391080373745485429146147669403317263780363306505857156064209602926535333071909491
= 42481052689091692859661163257336968116308378645346086679008747728668973847769
public_key = Complex(re=11048898386036746197306883207419421777457078734258168057000593553461884996107,
                     im=34230477038891719323025391618998268890391645779869016241994899690290519616973)
encrypted_flag = b'\'{\xda\xec\xe9\xa4\xc1b\x96\x9a\x8b\x92\x85\xb6&p\xe6W\x8axC)\xa7\x0f(N\xa1\x0b\x05\x19@<T>L9!\xb7\x9e3\xbc\x99\xf0\x8f\xb3\xacZ:\xb3\x1c\xb9\xb7;\xc7\x8a:\xb7\x10\xbd\x07"\xad\xc5\x84'
 
= 206109322179011817882783419945552366363
= 17175776848250984823565284995462697197
= 103054661089505908941391709972776183181
 
# solve for mod p
p_g = complex_pow(g, q * r, n)
p_enc = complex_pow(public_key, q * r, n)
 
# norm : a^2 + b^2
c_1 = norm(p_g) % (p * p)
c_2 = norm(p_enc) % (p * p)
 
c_1 = (c_1 - 1// p
c_2 = (c_2 - 1// p
 
val_p = (c_2 * inverse(c_1, p)) % p 
 
# solve for mod r
r_g = complex_pow(g, p * q, n)
r_enc = complex_pow(public_key, p * q, n)
print(norm(r_g) % p, norm(r_enc) % p)
 
'''
p = 206109322179011817882783419945552366363
g = GF(p)(176015758946526802279559144270141551487) # r_g
enc = GF(p)(28369875517706698292997652748535456248) # r_enc
print(g.multiplicative_order()) # this equals r
print(enc.log(g)) # 26176203815975575469683683780455489251
'''
 
val_r = 26176203815975575469683683780455489251
val_tot, pr = CRT(val_p, p, val_r, r)
 
for i in range(0100):
    private_key = long_to_bytes(val_tot + i * pr)
    flag = AES.new(private_key, AES.MODE_ECB).decrypt(encrypted_flag)
    if b"TetCTF" in flag:
        print(flag)
        break
 
# TetCTF{h0m0m0rph1sm_1s_0ur_fr13nd-mobi:*100*231199111007#}
cs


'CTF' 카테고리의 다른 글

CryptoHack All Solve, Again  (10) 2021.01.31
0x41 CTF SPN Writeup  (0) 2021.01.31
PBCTF 2020 Crypto Writeups  (1) 2020.12.07
N1CTF 2020 Crypto Write-Ups  (0) 2020.10.18
SECCON 2020 OnlineCTF Crypto Write-Ups  (0) 2020.10.11

I participated in PBCTF 2020 as a member of Super Guesser. We reached 2nd place :) 

Huge congratulations and respect to zer0pts, a team that clearly doesn't fit their team name.


I solved : QueenSarah2, Strong Cipher (with reversing help), LeaK, Special Gift, Special Gift Revenge, Find rbtree.


QueenSarah2


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
from string import ascii_lowercase
from itertools import product
from random import SystemRandom
from math import ceil, log
from secretstuff import FLAG
 
random = SystemRandom()
ALPHABET = ascii_lowercase + "_"
assert all(char in ALPHABET for char in FLAG)
 
bigrams = [''.join(bigram) for bigram in product(ALPHABET, repeat=2)]
random.shuffle(bigrams)
 
S_box = {}
for i in range(len(ALPHABET)):
    for j in range(len(ALPHABET)):
        S_box[ALPHABET[i]+ALPHABET[j]] = bigrams[i*len(ALPHABET) + j]
 
assert len(set(S_box.keys())) == 27*27
 
def encrypt(message):
    if len(message) % 2:
        message += "_"
 
    message = list(message)
    rounds = int(2 * ceil(log(len(message), 2))) # The most secure amount of rounds
 
    for round in range(rounds):
        # Encrypt
        for i in range(0len(message), 2):
            message[i:i+2= S_box[''.join(message[i:i+2])]
 
        # Shuffle, but not in the final round
        if round < (rounds-1):
            message = [message[i] for i in range(len(message)) if i%2 == 0+ [message[i] for i in range(len(message)) if i%2 == 1]
 
    return ''.join(message)
 
# 123456 -> 135246
if __name__ == "__main__":
    print("This is a restricted service! Decrypt this password to proceed:")
    print({encrypt(FLAG)})
 
    for _ in range(1500):
        question = input("> ").strip()
        assert 0 < len(question) <= 10000
 
        if not question:
            print("Bye.")
            break
 
        elif question == FLAG:
            print(f"You got it. The flag is pbctf{{{FLAG}}}")
            break
 
        else:
            print("That's not quite right. Your password encrypts to this:")
            print(encrypt(question))
 
 
cs


We have a secret permutation of bigrams, which are used to encrypt the messages. By sending all $27^2$ bigrams, we can easily calculate the square of the permutation. This is because with messages of length 2, we go through 2 rounds of encryption, and the last shuffle algorithm doesn't actually shuffle anything. However, knowing the square of the permutation does not imply we cannot recover the permutation directly. This is only possible in certain cases - such as when all cycles of the squared permutation have different length. This happens with around 2% probability. Therefore, we may try to recover the original permutation with this idea, and recover the flag by reversing the encryption process. I got it with around 20 tries, so I got lucky here a bit. Looking at the flag, it looks like there should be a cooler solution.


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
70
71
72
73
74
75
76
77
78
79
80
def connect():
    r = remote('queensarah2.chal.perfect.blue'1)
    return r
 
def get_index(t):
    if ord('a'<= ord(t) <= ord('z'):
        return ord(t) - ord('a')
    return 26 
 
def attempt():
    dcyc = [0* (27 * 27)
    AL = ascii_lowercase + "_"
    r = connect()
    r.recvline()
    true_val = r.recvline()[2:-3].decode()
    for i in range(027):
        for j in range(027):
            s = AL[i] + AL[j]
            print(s)
            r.sendline(s)
            r.recvline()
            res = r.recvline()[0:2].decode()
            idx = 27 * get_index(res[0]) + get_index(res[1])
            dcyc[27 * i + j] = idx # square of permutation
    vis = [0* (27 * 27 + 5)
    sz = [0* (27 * 27 + 5)
    fk = [0* (27 * 27 + 5)
    # get cycles of the square permutation 
    for i in range(027 * 27):
        if vis[i] == 1:
            continue
        cur, t = i, 0
        L = []
        while vis[cur] == 0:
            L.append(cur)
            t += 1
            vis[cur] = 1
            cur = dcyc[cur]
        if sz[t] >= 1 or t % 2 == 0# all cycle's length different
            return
        sz[t] += 1
        for x in L:
            fk[x] = t
    # compute original permutation
    cyc = [0* (27 * 27)
    for i in range(027 * 27):
        it = (fk[i] + 1// 2
        u = i
        for j in range(0, it):
            u = dcyc[u]
        cyc[i] = u
    invcyc = [0* (27 * 27)
    for i in range(027 * 27):
        invcyc[cyc[i]] = i
    # decryption process
    rounds = int(2 * math.ceil(math.log(len(true_val), 2)))
    for i in range(0, rounds):
        if i != 0:
            msg = ''
            for j in range(0len(true_val) // 2):
                msg = msg + true_val[j]
                msg = msg + true_val[j + len(true_val) // 2]
            true_val = msg
        msg = ''
        for j in range(0len(true_val), 2):
            cc = 27 * get_index(true_val[j]) + get_index(true_val[j+1])
            cc = invcyc[cc]
            u = cc // 27
            v = cc % 27
            msg += AL[u]
            msg += AL[v]
        true_val = msg
    print(true_val)
    r.sendline(true_val)
    print(r.recvline())
 
NUM_ATTEMPT = 1000
for i in range(0, NUM_ATTEMPT):
    print("[+] Attempt ", i)
    attempt()
cs


Strong Cipher


After reversing, we found out that the encryption process was a simple vigenere cipher, with $GF(2^8)$ multiplications instead of addition. By brute forcing key length and selecting keys that gave maximum number of readable characters, we can recover the plaintext. I wasted a lot of time by incorrectly assuming that the plaintext is 100% readable. oof


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
def gf2(src1, src2):
    ret = 0
    for i in range(08):
        if (src2 & (1 << i)) > 0:
            ret = ret ^ (src1 << i)
    for i in range(147-1):
        p = 0x11B << (i - 8)
        if (ret & (1 << i)) > 0:
            ret = ret ^ p
    assert 0 <= ret < 256
    return ret
 
= open("ciphertext""rb")
= f.read()
 
= len(f)
print(L)
 
cc = []
for i in range(1213): # should brute over 1 ~ 16
    for j in range(0, i):
        cmx, whi = 00
        for k in range(1256):
            cnt = 0
            for l in range(j, L, i):
                t = gf2(k, f[l]) 
                if 32 <= t <= 128:
                    cnt += 1
            if cmx < cnt:
                cmx = cnt
                whi = k
        cc.append(whi)
 
res = b""
 
for i in range(0, L):
    res += long_to_bytes(gf2(cc[i % 12], f[i]))
 
print(res)
cs


LeaK


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
from Crypto.Cipher import AES
from Crypto.Util.Padding import pad, unpad
from ecdsa import SECP256k1
from ecdsa.ecdsa import Public_key, Private_key
from flag import flag
import hashlib
import random
 
= SECP256k1.generator
order = int(SECP256k1.order)
secret = random.randrange(2, order - 1)
pubkey = Public_key(g, g * secret)
privkey = Private_key(pubkey, secret)
 
arr = []
for i in range(30):
    h = random.randrange(2, order - 1)
    k = random.randrange(2, order - 1)
    sig = privkey.sign(h, k)
    
    lea_k = int("0x" + "{:064x}".format(k)[10:-10], 16)
 
    arr.append((h, lea_k, int(sig.r), int(sig.s)))
 
print(arr)
 
sha256 = hashlib.sha256()
sha256.update(str(secret).encode())
key = sha256.digest()
 
aes = AES.new(key, mode=AES.MODE_ECB)
print(aes.encrypt(pad(flag, 16)).hex())
cs


It's yet another hidden number problem. We have

$$s \equiv k^{-1}(h + r \cdot pvk) \pmod{n}$$ $$ (2^{216} \cdot small_1 + 2^{40} \cdot leak + small_2)s \equiv h + r \cdot pvk \pmod{n}$$ $$ r \cdot pvk \equiv 2^{216}s \cdot small_1 + s \cdot small_2 + 2^{40}s \cdot leak - h \pmod{n}$$

Now we can work with $r$, $2^{216}s$, $s$ and $n$ to make $2^{40}s \cdot leak - h$. Babai's algorithm can do this.


Small notes on the code below - I have used the following tricks to get the secret key.

  • The equation above is an equality, so we have to force it to be equal. 
  • This is done by scaling - i.e. multiplying a super large integer $n^2$ to the respective columns.
  • We have $pvk$ to be between $0$ and $n$, but $small_1, small_2$ between $0$ and $2^{40}$. 
  • We need the two to have a similar order for CVP algorithm to respect the bounds on $small_1, small_2$, so scale it by $n/2^{40}$. 
  • We have 30 signatures, but with the matrix below, using all of them takes too long. Using 10 is enough.


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
def Babai_closest_vector(M, G, target):
        small = target
        for _ in range(1):
            for i in reversed(range(M.nrows())):
                c = ((small * G[i]) / (G[i] * G[i])).round()
                small -=  M[i] * c
        return target - small 
 
NUM = 10
= 115792089237316195423570985008687907852837564279074904382605163141518161494337
SIG = [] ## signature
 
= Matrix(ZZ, 3 * NUM + 13 * NUM + 1)
for i in range(0, NUM):
    M[0, i] = SIG[i][2* n * n
M[0, NUM] = 1
 
for i in range(0, NUM):
    M[2 * i + 1, i] = (2 ** 216* SIG[i][3* n * n
    M[2 * i + 2, i] = SIG[i][3* n * n
    M[2 * i + 1, NUM + 2 * i + 1= n // (2 ** 40)
    M[2 * i + 2, NUM + 2 * i + 2= n // (2 ** 40)
for i in range(0, NUM):
    M[2 * NUM + 1 + i, i] = n * n * n
Target = [0* (3 * NUM + 1)
for i in range(0, NUM):
    Target[i] = ((SIG[i][1* (2 ** 40* SIG[i][3- SIG[i][0]) % n) * n * n
Target = vector(Target)
= M.LLL()
GG = M.gram_schmidt()[0]
TT = Babai_closest_vector(M, GG, Target)
print(TT - Target)
print(TT[NUM] % n) ## secret
cs


Special Gift


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
from Crypto.Util.number import getStrongPrime, inverse, bytes_to_long, GCD as gcd
from Crypto.Random.random import randint
from flag import flag
 
= getStrongPrime(512)
= getStrongPrime(512)
= p * q
phi = (p - 1* (q - 1)
 
# Hehe, boi
while True:
    d = randint(int(N ** 0.399), int(N ** 0.4))
    if gcd(d, phi) == 1:
        break
 
= inverse(d, phi)
 
# Here's a special gift. Big.
gift = d >> 120
 
enc = pow(bytes_to_long(flag), e, N)
 
print("N =", N)
print("e =", e)
print("gift =", gift)
print("enc =", enc)
cs


My best achievement in this CTF is first solving this with unintended sol, forcing rbtree to write a revenge challenge.

This problem is also the reason I'm writing stuff up for this CTF in 2am ^__^

My solution resembles my alternate solution for crypto01 from SECCONCTF a little bit.


We start with $$ ed = k\phi(n) + 1 = k(n-p-q+1)+1 = kn - k(p+q-1) + 1$$

Since $e \approx n$ and $d \approx n^{0.4}$, we have $k \approx n^{0.4}$ as well. Also, we know that $$ 2\sqrt{n} \le p+q \le 3\sqrt{n/2}$$ so combining, we have something like $0 \le k(p+q-1) - 1 \le 3 \cdot n^{0.9}$. I calculated constants like $3$ very loosely, btw. 


Therefore, we have an interval of length $3 \cdot n^{0.9}$ that $ed \pmod{n}$ must lie inside. 

Writing $d = gift \cdot 2^{120} + c$ with $0 \le c < 2^{120}$, we have an interval on $ec \pmod{n}$. 


We have $2^{120}$ candidates for $c$, and we have a condition on $ec \pmod{n}$ that has, heuristically, a probability of $3 \cdot n^{-0.1}$ of satisfying. Therefore, we can expect around $2^{120} \cdot 3 \cdot n^{-0.1} \approx 3 \cdot 2^{18}$ "true" candidates for $c$. This is not an unreasonably large number, so if we can find those true candidates quickly, we can brute force every one of them to find the flag. 


How do we find the true candidates for $c$? This is equivalent to finding the smallest positive integer $x$ such that $$L \le Ax \pmod{M} \le R$$ for given $A, M, L, R$ quickly. Polynomial time solution of this problem was mentioned in the crypto01 problem writeup linked above. 

It takes around 30 minutes to compute the flag, and it iterates over around 700,000 candidates as expected.


The flag prints the link to a paper that contains the intended solution.


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
def ceil(n, m):
    return (n + m - 1// m
 
def optf(A, M, L, R):
    if L == 0:
        return 0
    if 2 * A > M:
        L, R = R, L
        A = M - A
        L = M - L
        R = M - R
    cc_1 = ceil(L, A)
    if A * cc_1 <= R:
        return cc_1
    cc_2 = optf(A - M % A, A, L % A, R % A)
    return ceil(L + M * cc_2, A)
 
= 124588792854585991543122421017579759242707321792822503200983206042530513248160179498235727796077646122690756838184806567078369714502863053151565317001149999657802192888347495811627518984421857644550440227092744651891241056244522365071057538408743656419815042273198915328775318113249292516318084091006804073157
= 109882604549059925698337132134274221192629463500162142191698591870337535769029028534472608748886487359428031919436640522967282998054300836913823872240009473529848093066417214204419524969532809574214972094458725753812433268395365056339836734440559680393774144424319015013231971239186514285386946953708656025167
gift = 870326170979229749948990285479428244545993216619118847039141213397137332130507928675398
enc = 67594553703442235599059635874603827578172490479401786646993398183588852399713973330711427103837471337354320292107030571309136139408387709045820388737058807570181494946004078391176620443144203444539824749021559446977491340748598503240780118417968040337516983519810680009697701876451548797213677765172108334420
 
CR = (-* (gift << 120)) % N
CL = (-* (gift << 120- 3 * (int)(N ** 0.9)) % N
 
lst = 0
 
while lst <= (2 ** 120):
    # find next solution by shifting
    NL = (CL - e * (lst + 1)) % N
    NR = (CR - e * (lst + 1)) % N
    if NL > NR:
        # 0 is a solution
        lst = lst + 1
    else:
        # find actual solution
        cc = optf(e, N, NL, NR)
        lst = lst + 1 + cc
    real_d = gift * (1 << 120+ lst
    s = long_to_bytes(pow(enc, real_d, N))
    if b"pbctf" in s:
        print(s)
cs


Special Gift Revenge


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
from Crypto.Util.number import getStrongPrime, inverse, bytes_to_long, GCD as gcd
from Crypto.Random.random import randint
from flag import flag
import gmpy2
 
= getStrongPrime(512)
= getStrongPrime(512)
= p * q
phi = (p - 1* (q - 1)
 
# Hehe, boi
while True:
    d = randint(int(N ** gmpy2.mpfr(0.599)), int(N ** gmpy2.mpfr(0.6)))
    if gcd(d, phi) == 1:
        break
 
= inverse(d, phi)
 
# Here's a special gift. Big.
gift = d >> 120
 
enc = pow(bytes_to_long(flag), e, N)
 
print("N =", N)
print("e =", e)
print("gift =", gift)
print("enc =", enc)
cs


Now this problem blocks the unintended sol above. Therefore, we should implement the paper in the previous flag.

The implementation is not tough, but I surely learned a lot of lattice stuff from it. Thanks to rbtree :)


The flag is pbctf{thank_you_rkm0959,_for_finding_unintended_solution} which is pretty cool :P


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
70
71
## modified https://github.com/ubuntor/coppersmith-algorithm/blob/main/coppersmith.sage
 
debug = True
 
= 123463519828344660835965296108959625188149729700517379543746606603601816029557213728343115758280318474617032830851553509268562367217512005079977122560679743955588214135519642513042848616372204042776892196887455692479457740367547908255044784496969010537283159300508751036032559594474145098337531029291955103059
= 85803665824396212221464259773478155183477895540333642019501498374139506738444521180470104195883386495607712971252463223185914391456070458788554837326327618859712794129800329295751565279950274474800740076285111503780662397876663144946831503522281710586712396810593754749589799811545251575782431569881989690861
gift = 46710143823773072238724337855139753113453277386728402328859555407710009799097841900723288768522450009531777773692804519189753306306645410280934372812
enc = 106121451638162677594573310940827829041097305506084523508481527070289767121202640647932427882853090304492662258820333412210185673459181060321182621778215705296467924514370932937109363645133019461501960295399876223216991409548390823510949085131028770701612550221001043472702499511394058569487248345808385915190
 
delta = 0.6
gamma = 120 / 1022
lam = max(gamma, delta - 0.5)
d_0 = (gift << 120+ (1 << 119
k_0 = (e * d_0 - 1// N
= (int)(4 * (N ** lam))
= (int)(3 * ((N/2** 0.5))
= 5
= 3
 
P.<x,y> = PolynomialRing(ZZ)
pol = (1 + k_0 * N) % e + (k_0 % e) * y + (N % e) * x + x * y
 
while gcd(pol(0,0), X) != 1:
    X = next_prime(X, proof=False)
 
while gcd(pol(0,0), Y) != 1:
    Y = next_prime(Y, proof=False)
 
polynomials = []
for j in range(0, m+1):
    for i in range(0, m-j+t+1):
        polynomials.append(x^i * pol^j * e^(m-j))
for j in range(0, m+1):
    for i in range(1, m-j+1):
        polynomials.append(y^i * pol^j * e^(m-j))
 
monomials = []
for i in polynomials:
    for j in i.monomials():
        if j not in monomials:
            monomials.append(j)
monomials.sort()
 
= matrix(ZZ,len(monomials))
for i in range(len(monomials)):
    for j in range(len(monomials)):
        L[i,j] = polynomials[i](X*x,Y*y).monomial_coefficient(monomials[j])
 
= matrix(ZZ,sorted(L,reverse=True))
= L.LLL()
roots = []
 
for i in range(L.nrows()):
    for j in range(i+1, L.nrows()):
        pol1 = P(sum(map(mul, zip(L[i],monomials)))(x/X,y/Y))
        pol2 = P(sum(map(mul, zip(L[j],monomials)))(x/X,y/Y))
        r = pol1.resultant(pol2, y)
        if r.is_constant():
            continue
        for x0, _ in r.univariate_polynomial().roots():
            if x0 in [i[0for i in roots]:
                continue
            if debug:
                print("Potential x0:",x0)
            for y0, _ in pol1(x0,y).univariate_polynomial().roots():
                if debug:
                    print("Potential y0:",y0)
                if (x0,y0) not in roots:
                    roots.append((x0,y0))
print(roots)
 
cs


Find rbtree


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
70
71
72
73
74
75
76
prop = {
    "Eyewear": ["Glasses""Monocle""None"],
    "Eye color": ["Brown""Blue""Hazel"],
    "Hair": ["Straight""Curly""Bald"],
    "Outerwear": ["Coat""Hoodie""Poncho"],
    "T-shirt color": ["Red""Orange""Green"],
    "Trousers": ["Jeans""Leggings""Sweatpants"],
    "Socks color": ["Black""Gray""White"],
    "Shoes": ["Boots""Slippers""Sneakers"],
}
 
 
def stage(num_stage, num_people, num_ask):
    print("STAGE {} / 30".format(num_stage))
    print("Generating people... (and rbtree)")
 
    people = list(itertools.product(*prop.values()))
    random.shuffle(people)
    people = people[:num_people]
    rbtree = random.choice(people)
 
    print("=" * 29)
    for idx, person in enumerate(people):
        print(" ".join(" [PERSON {:4d}] ".format(idx + 1)))
        for prop_name, prop_val in zip(prop.keys(), person):
            print("{:14s}: {}".format(prop_name, prop_val))
        print("=" * 29)
 
    print("Now ask me!")
 
    for i in range(num_ask):
        prop_name = input("? > ")
        if prop_name == 'Solution':
            break
        if prop_name not in prop:
            return False
        
        prop_ask = input("! > ").strip().split(' ')
        for val in prop_ask:
            if val not in prop[prop_name]:
                return False
 
        if set(rbtree) & set(prop_ask):
            print("YES")
        else:
            print("NO")
 
    rbtree_guess = tuple(input("rbtree > ").strip().split(' '))
 
    if rbtree == rbtree_guess:
        return True
    else:
        return False
 
 
def main():
 
    cases = [(53), (73), (104), (154), (205), (255), (506), (757), (1008), (2509)]
    cases += [(40010)] * 5 + [(75011)] * 5 + [(100012)] * 5 + [(160012)] * 5
 
    for idx, (num_people, num_ask) in enumerate(cases):
        if not stage(idx + 1, num_people, num_ask):
            print("WRONG :(")
            return
        print("You found rbtree!")
              
    with open("flag.txt""r"as f:
        print(f.read())
 
 
if __name__ == "__main__":
    try:
        main()
    finally:
        print("BYEBYE!")
        exit(0)
cs


I tried the most naive way - finding the question that has "the best worst case". If I had used all my queries and I still had multiple candidates left, I chose one randomly and reported it as the answer. It looked it had decent chances of succeeding, so my teammates ran multiple instances :) I was worried about the server being adaptive like some challenges in competitive programming, but we got the flag.


UPD: So more of a backstory - I had realized that the number of candidates never decreased by more than half. This is strange unless the server is set up against you. This was the main fact that caused me to become worried about adversarial server. Turns out the server *was* adversarial, but wasn't perfectly so. The solution below is clearly unintended (smarter solutions should exist) but the server's antics can't really stop it from getting the flag. I guess they didn't block choosing one random answer while there are multiple candidates. 


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
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
prop_list = ["Eyewear""Eye color""Hair""Outerwear""T-shirt color""Trousers""Socks color""Shoes"]
prop = {
    "Eyewear": ["Glasses""Monocle""None"],
    "Eye color": ["Brown""Blue""Hazel"],
    "Hair": ["Straight""Curly""Bald"],
    "Outerwear": ["Coat""Hoodie""Poncho"],
    "T-shirt color": ["Red""Orange""Green"],
    "Trousers": ["Jeans""Leggings""Sweatpants"],
    "Socks color": ["Black""Gray""White"],
    "Shoes": ["Boots""Slippers""Sneakers"],
}
 
def checker(i, j, desc):
    for t in range(03):
        if (1 << t) & j and desc[i] == prop[prop_list[i]][t]:
            return True
    return False
 
def ask_query(whi_i, whi_j):
    r.sendline(prop_list[whi_i])
    s = ""
    for i in range(03):
        if whi_j & (1 << i):
            s += prop[prop_list[whi_i]][i]
            s += " "
    s = s[:-1]
    r.sendline(s)
    res = r.recvline()
    print(res)
    sx = False
    if b"YES" in res:
        sx = True
    return sx
 
def gogo(ppl_desc, num_query):
    print(len(ppl_desc))
    if len(ppl_desc) == 1:
        if num_query >= 1:
            r.sendline(b"Solution")
        s = ""
        for i in range(08):
            s += ppl_desc[0][i]
            if i != 7:
                s += " "
        r.sendline(s)
        r.recvline()
        return
    if num_query == 0:
        t = random.randrange(0len(ppl_desc))
        s = ""
        for i in range(08):
            s += ppl_desc[t][i]
            if i != 7:
                s += " "
        r.sendline(s)
        r.recvline()
        return
    T = len(ppl_desc)
    whi_i, whi_j, opt = -1-10
    for i in range(08):
        for j in range(14):
            cnt = 0
            for k in range(0, T):
                ex = checker(i, j, ppl_desc[k])
                if ex == True:
                    cnt += 1
            if min(cnt, T-cnt) > opt:
                opt = min(cnt, T-cnt)
                whi_i, whi_j = i, j
    sx = ask_query(whi_i, whi_j)
    nppl_desc = []
    for k in range(0, T):
        ex = checker(whi_i, whi_j, ppl_desc[k])
        if ex == sx:
            nppl_desc.append(ppl_desc[k])
    gogo(nppl_desc, num_query - 1)
    return
 
def solve(num_pp, num_ask):
    print(r.recvline())
    print(r.recvline())
    print(r.recvline())
    ppl_desc = []
    for i in range(0, num_pp):
        cur = []
        r.recvline()
        for j in range(08):
            s = r.recvline()
            s = s.split(b":")[1]
            s = s[1:-1].decode()
            cur.append(s)
        ppl_desc.append(cur)
        r.recvline()
    r.recvline()
    gogo(ppl_desc, num_ask)
 
 
= remote("find-rbtree.chal.perfect.blue"1)
 
for i in range(09):
    r.recvline()
 
cases = [(53), (73), (104), (154), (205), (255), (506), (757), (1008), (2509)]
cases += [(40010)] * 5 + [(75011)] * 5 + [(100012)] * 5 + [(160012)] * 5
 
for i in range(030):
    solve(cases[i][0], cases[i][1])
 
print(r.recvline())
cs


'CTF' 카테고리의 다른 글

0x41 CTF SPN Writeup  (0) 2021.01.31
TetCTF 2021 Crypto Writeups  (1) 2021.01.03
N1CTF 2020 Crypto Write-Ups  (0) 2020.10.18
SECCON 2020 OnlineCTF Crypto Write-Ups  (0) 2020.10.11
CryptoHack All Solve  (3) 2020.09.30

I participated in N1CTF as a member of Super Guesser. 4th Place :)

We solved all crypto problems, and it was a great collaboration of me and rbtree. 

The explanation will be very brief, because I don't have a lot of free time on my hands :( :(


VSS


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/python3
import qrcode  # https://github.com/lincolnloop/python-qrcode
import random
import os
from PIL import Image
from flag import FLAG
 
 
def vss22_gen(img):
    m, n = img.size
    share1, share2 = Image.new("L", (2*m, 2*n)), Image.new("L", (2*m, 2*n))
    image_data = img.getdata()
    flipped_coins = [int(bit) for bit in bin(random.getrandbits(m*n))[2:].zfill(m*n)]
    for idx, pixel in enumerate(image_data):
        i, j = idx//n, idx % n
        color0 = 0 if flipped_coins[idx] else 255
        color1 = 255 if flipped_coins[idx] else 0
        if pixel:
            share1.putpixel((2*j, 2*i), color0)
            share1.putpixel((2*j, 2*i+1), color0)
            share1.putpixel((2*j+12*i), color1)
            share1.putpixel((2*j+12*i+1), color1)
 
            share2.putpixel((2*j, 2*i), color0)
            share2.putpixel((2*j, 2*i+1), color0)
            share2.putpixel((2*j+12*i), color1)
            share2.putpixel((2*j+12*i+1), color1)
        else:
            share1.putpixel((2*j, 2*i), color0)
            share1.putpixel((2*j, 2*i+1), color0)
            share1.putpixel((2*j+12*i), color1)
            share1.putpixel((2*j+12*i+1), color1)
 
            share2.putpixel((2*j, 2*i), color1)
            share2.putpixel((2*j, 2*i+1), color1)
            share2.putpixel((2*j+12*i), color0)
            share2.putpixel((2*j+12*i+1), color0)
    share1.save('share1.png')
    share2.save('share2.png')
 
 
def vss22_superposition():
    share1 = Image.open('share1.png')
    share2 = Image.open('share2.png')
    res = Image.new("L", share1.size, 255)
    share1_data = share1.getdata()
    share2_data = share2.getdata()
    res.putdata([p1 & p2 for p1, p2 in zip(share1_data, share2_data)])
    res.save('result.png')
 
 
def main():
    qr = qrcode.QRCode(
        version=1,
        error_correction=qrcode.constants.ERROR_CORRECT_L,
        box_size=12,
        border=4,
    )
    qr.add_data(FLAG)
    qr.make(fit=True)
    img = qr.make_image(fill_color="black", back_color="white")
    vss22_gen(img._img)
    img.save('res.png')
    vss22_superposition()
 
 
if __name__ == '__main__':
    main()
 
cs


The vulnerability lies in the use of "getrandbits" - it's implemented using MT19937. This PRNG is known to be predictable after 624 output values are known. Also, if you try to generate a QR code with a sample flag, we can see that the first few rows of the generated output are all white. With this fact, we can retrieve the first few thousand bits generated, and we can predict the following bits generated by MT19937. Therefore, we can recover the original QR code. This solution (and code itself) is due to rbtree. 


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
from mt import untemper
import random
from PIL import Image
 
img = Image.open('share2.png')
value = 0
for i in range(444):
    for j in range(444):
        value <<= 1
        value ^= 1 if 255 == img.getpixel((2 * j + 12 * i)) else 0
 
tmp = value
values = []
for i in range(444 * 444 // 32):
    values.append(tmp & 0xffffffff)
    tmp >>= 32
 
mt_state = tuple(list(map(untemper, values[:624])) + [0])
random.setstate((3, mt_state, None))
 
# for i in range(444 * 444 // 32):
#     assert values[i] == random.getrandbits(32)
 
random.setstate((3, mt_state, None))
 
real_value = 0
for i in range(444 * 444 // 32):
    real_value ^= random.getrandbits(32<< (32 * i)
 
value ^= real_value
arr = [int(bit) for bit in bin(value)[2:].zfill(444 * 444)]
 
res = Image.new("L", (444444))
 
for i in range(444):
    for j in range(444):
        res.putpixel((j, i), 0 if arr[i * 444 + j] else 255)
    
res.save("res.png")
cs


FlagBot


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
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
from hashlib import sha256
from Crypto.Cipher import AES
from Crypto.Util.number import long_to_bytes, bytes_to_long
from Crypto.Util.Padding import pad, unpad
import base64
from secret import flag
 
RECEIVER_NUM = 7
 
def generate_safecurve():
    while True:
        p = random_prime(2 ^ 256-1False2 ^ 255)
        a = randint(-p, p)
        b = randint(-p, p)
 
        if 4*a^3 + 27*b^2 == 0:
            continue
 
        E = EllipticCurve(GF(p), [a, b])
 
        fac = list(factor(E.order()))
 
        # Prevent rho method
        if fac[-1][0< 1 << 80:
            continue
 
        # Prevent transfer
        for k in range(120):
            if (p ^ k - 1) % fac[-1][0== 0:
                break
        else:
            return E
 
class Sender:
    def __init__(self, curves, receivers):
        self.secret = randint(1 << 2541 << 255)
        self.curves = curves
        self.receivers = receivers
        self.shared_secrets = [None for _ in range(len(receivers))]
 
    def setup_connections(self):
        for idx, receiver in enumerate(self.receivers):
            curve = self.curves[idx]
            print(f"curves[{idx}] : {curve}")
            g = self.curves[idx].gens()[0]
            print(f"g[{idx}] = {g.xy()}")
            receiver.set_curve(curve, g)
            public = self.secret * g
            print(f"S_pub[{idx}] = {public.xy()}")
            yours = receiver.key_exchange(public)
            print(f"R_pub[{idx}] = {yours.xy()}")
            self.shared_secrets[idx] = yours * self.secret
 
    def send_secret(self):
        msg = b'Hi, here is your flag: ' + flag
        for idx, receiver in enumerate(self.receivers):
            px = self.shared_secrets[idx].xy()[0]
            _hash = sha256(long_to_bytes(px)).digest()
            key = _hash[:16]
            iv = _hash[16:]
            encrypted_msg = base64.b64encode(AES.new(key, AES.MODE_CBC, iv).encrypt(pad(msg, 16)))
            print(f"encrypted_msg[{idx}] = {encrypted_msg}")
            receiver.receive(encrypted_msg)
 
 
class Receiver:
    def __init__(self):
        self.secret = randint(1 << 2541 << 255)
        self.curve = None
        self.g = None
        self.shared_secret = None
 
    def set_curve(self, curve, g):
        self.curve = curve
        self.g = g
 
    def key_exchange(self, yours):
        self.shared_secret = yours * self.secret
        return self.g * self.secret
 
    def receive(self, encrypted_msg):
        px = self.shared_secret.xy()[0]
        _hash = sha256(long_to_bytes(px)).digest()
        key = _hash[:16]
        iv = _hash[16:]
        msg = AES.new(key, AES.MODE_CBC, iv).decrypt(base64.b64decode(encrypted_msg))
        msg = unpad(msg, 16)
        assert msg.startswith(b'Hi, here is your flag: ')
 
 
receivers = [Receiver() for _ in range(RECEIVER_NUM)]
curves = [generate_safecurve() for _ in range(RECEIVER_NUM)]
 
= Sender(curves, receivers)
A.setup_connections()
A.send_secret()
 
cs


This problem was solved by me, so I can give you a brief look inside my brain.

  • You can't really do anything with AES-CBC in this challenge
  • You can't really do anything with SHA256 anywhere
  • That means that we have to break the DH style shared secret generation
  • The secret key is reused every time, so that's the vulnerability
  • The curve generation only checks the existence of large primes, so small primes can exist

At this point, the solution was straightforward. For each small prime $p$ that divides the order of the elliptic curve used, we can find the value of the secret key $\pmod{p}$ by using Pohlig-Hellman approach. Combining these with CRT, we can recover $d$ since it is at most $2^{255}$. 

Since we do need to deal with primes of size around $10^{12}$, Baby-Step-Giant-Step is required. Just use Sage.


The below code finds all the shared secrets. The remaining parts of the problem are straightforward.


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
cur_mod = 1
cur_val = 0
 
for i in range(07):
    a = S[i][0]
    b = S[i][1]
    p = S[i][2]
    E = EllipticCurve(GF(p), [a, b])
    Ord = E.order()
    L = list(factor(Ord))
    GG = E(g[i])
    SS = E(S_pub[i])
    for pp, dd in L:
        if pp <= 10 ** 12 and dd == 1:
            Gp = (Ord // pp) * GG
            Sp = (Ord // pp) * SS
            tt = discrete_log(Sp, Gp, operation='+')
            cur_val = crt(cur_val, tt, cur_mod, pp)
            cur_mod = (cur_mod * pp) // gcd(pp, cur_mod)
    print("Done ", i)
    
print("[+] Secret: ", cur_val)
 
for i in range(07):
    a = S[i][0]
    b = S[i][1]
    p = S[i][2]
    E = EllipticCurve(GF(p), [a, b])
    RR = E(R_pub[i])
    RES = RR * cur_val
    print(RES.xy()[0])
cs


curve


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
70
71
72
73
74
75
76
77
78
#!/usr/bin/env sage
 
import signal, hashlib, string, random, os 
 
os.chdir(os.path.dirname(os.path.abspath(__file__)))
FLAG = open("./flag.txt"'r').read()
ROUNDS = 30
 
def PoW():
  s = ''.join([random.choice(string.ascii_letters + string.digits) for _ in range(20)])
  h = hashlib.sha256(s.encode()).hexdigest()
  prefix = s[:16]
  print("sha256(%s+XXXX) == %s" % (prefix, h))
  c = input("Give me XXXX: ")
  if hashlib.sha256((prefix + c).encode()).hexdigest() == h:
    return True 
  return False
 
def chall():
  p = ZZ(input("P: "))  # of course we are using sage >= 9
  a = ZZ(input("A: "))
  b = ZZ(input("B: "))
 
  if not is_prime(p) or p.nbits() < 512:
    print("No bad parameters.")
    return
 
  E = EllipticCurve(GF(p), [a, b])
  if E.is_supersingular():
    print("No this is not good enough.")
    return
 
  q = E.order()
  x1 = ZZ(input("X1: "))
  y1 = ZZ(input("Y1: "))
  x2 = ZZ(input("X2: "))
  y2 = ZZ(input("Y2: "))
  G1 = E((x1, y1))
  G2 = E((x2, y2))
 
  for _ in range(ROUNDS):
    a0 = randint(1, q - 1)
    a1 = randint(1, q - 1)
 
    c = -1
    while c == -1 or c == a0 * a1:
      c = randint(1, q - 1)
 
    g0, g1 = G1 * a0, G2 * a1 
    c0, c1 = G1 * (a0 * a1), G1 * c
    b = randint(01)
 
    if b == 0:
      print(g0, g1, c0)
    else:
      print(g0, g1, c1)
 
    choice = ZZ(input("Choice: "))
    if choice != b:
      print("Wrong choice.")
      return
 
  print(f"Thank you! Here's your reward: {FLAG}")
  return 
 
if __name__ == '__main__':
  if not PoW():
    print("Invalid PoW.")
    exit()
  signal.alarm(90)
 
  try:
    chall()
  except:
    print("oof...")
    exit()
 
 
cs


We struggled greatly on this challenge, despite finding the solution quite immediately. Here's the process.

  • $E$ is a large elliptic curve over a prime field, not supersingular
  • We select two points $G_1, G_2$ on the curve, and play a sort of Decisional Diffie-Hellman game.
  • Let's just fix $G = G_1 = G_2$ and see what we can do!
  • If the order of $G$ is small, say $t$ - we can recover $a_0, a_1 \pmod{t}$ easily. 
  • Therefore, we can directly calculate $a_0a_1G$ as well!
  • Check if this is equal to the third point given. If so, check $b=0$ and otherwise check $b=1$.
Now we do some analysis. 
  • If $b=0$ was chosen, this will give the correct answer with probability 1
  • If $b=1$ was chosen, we fail if $c \equiv a_0 a_1 \pmod{t}$, so we succeed with probability $1-1/t$
  • We do $30$ rounds, so $t$ shouldn't be too small (we want, say, $t> 50$)
  • Obviously we do need to solve the discrete logarithm on a group of order $t$, so we want small $t$

This leads to the following goal.

  • Find an elliptic curve that satisfies the server's desired conditions, with the order of the curve having a prime between $50$ and $400$

I think I took about 5 minutes until here, but the journey towards the flag for several reasons.

  • First, my initial code used random $p, a, b$, generate the curve, find the order, then check for small primes.
  • Seems good right? However, I tried to find $G$ using E.gens()[0] * (Order // small_prime)
  • This obviously takes infinite time, and my computer was on the verge of dying because of it
  • I realized the problem and replaced it by generating any point with Tonelli-Shanks and multiplying it by Order // small_prime

So I thought I was done here. After sending the parameters, I realized that I was not done here. Why?

  • The server calculates the order of the elliptic curve as well
  • While order calculation is done in polynomial time, it's still slow with 512 bit curve parameters
  • Therefore, the server times out, and I can't solve the problem

So I thought I was done here, in a different meaning. After solving the remaining challs, I tried the following.

  • Simply try the curves of the form $y^2 = x^3 + b$.

This worked, as the order calculation was done much faster. The challenge was solved.

The parameter finding and solution finding was done by me, and the programming was done by rbtree.


I learned a lot from solving this problem :) I'm still inexperienced, lots of studying to do... 


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
pr = []
for x in range(50200):
    if x in Primes():
        pr.append(x)
while True:
    p = random_prime(2 ** 512False2 ** 511)
    if p % 3 == 2## in this case, y^2 = x^3 + b is guaranteed to be supersingular
        continue
    d = randint(1, p-1)
    E = EllipticCurve(GF(p), [0, d])
    if E.is_supersingular() == True:
        continue
    print(p)
    L = E.order()
    for cc in pr:
        if L % cc == 0:
            print(p, d, cc, L)
            break
 
## find any point on the elliptic curve
for u in range(1100):
    goal = (u ** 3 + a * u + b) % p
    if pow(goal, (p-1// 2, p) == 1:
        v = tonelli(goal, p) ## sqrt, so you can directly use sage
        G = E(u, v)
        break
 
## hope that G is nonzero
= G * (Ord // pr)
G1 = G
G2 = G
 
## this ends parameter generation
cs


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
## by rbtree
 
from pwn import *
import string
import itertools
 
conn = remote('47.242.140.57'9998)
conn.settimeout(None)
 
# PoW
 
challenge = conn.recvline().strip()
print(challenge)
prefix = challenge[7:7+16]
= challenge.split()[-1]
charset = (string.ascii_letters + string.digits).encode()
for suffix in itertools.product(charset, repeat=4):
    if hashlib.sha256(prefix+bytes(suffix)).hexdigest() == h.decode():
        conn.sendlineafter(b'Give me XXXX: ', bytes(suffix))
        break
print("PoW Done")
 
= 11572562087281212077294341316763410822093276559896892655806738743748493229131824454041157658617469079306138012813995393545636120267619633658087398895787057 
= 0
= 587626359248673832094266933340735482471140319598254235432650868938827936103013631493279303809976008538035914917596142929543705518144408460458007005924570
pr = 97
order = 11572562087281212077294341316763410822093276559896892655806738743748493229131918957581964494921602014693617723606720177358361724985583223555103419211299648
 
Gs = [] ## bunch of points (G, 2G, ... 97G)
print(len(Gs))
 
def get_points():
    points_s = conn.recvline().decode().strip()[1:-1].split(') (')
    points = []
    for point_s in points_s:
        point = tuple(int(v.strip()) for v in point_s.split(':')[:2])
        points.append(point)
    return points
 
conn.sendlineafter(b'P: 'str(p))
conn.sendlineafter(b'A: 'str(a))
conn.sendlineafter(b'B: 'str(b))
conn.sendlineafter(b'X1: 'str(Gs[0][0]))
conn.sendlineafter(b'Y1: 'str(Gs[0][1]))
conn.sendlineafter(b'X2: 'str(Gs[0][0]))
conn.sendlineafter(b'Y2: 'str(Gs[0][1]))
 
print("Parameter Sent")
 
for _ in range(30):
    points = get_points()
 
    for i in range(96):
        if points[0== Gs[i]:
            a = i + 1
            break
 
    for i in range(96):
        if points[1== Gs[i]:
            b = i + 1
            break
 
    to_send = 0 if Gs[(a * b % 97- 1== points[2else 1
    conn.sendlineafter(b'Choice: 'str(to_send))
 
conn.interactive()
cs


BabyProof


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
from hashlib import sha256
 
from Crypto.Util.number import getRandomRange
from Crypto.PublicKey import DSA
 
from secret import proof_of_work, flag
 
 
= int.from_bytes(flag, 'big')
assert x.bit_length() == 247
 
 
def baby_proof():
    key = DSA.generate(3072)  # It takes time to generate, plz be patient...
    p, q, g = key.domain()
    y = pow(g, x, p)
 
    v = getRandomRange(1, x)
    t = pow(g, v, p)
 
    gyt = b"".join(
        map(
            lambda x: int.to_bytes(len(str(x)), 4'big'+ str(x).encode(),
            (g, y, t)
        ))
    c = int.from_bytes(sha256(gyt).digest(), 'big')
    r = (v - c*x) % q
 
    print("I want to prove to you that I am in the knowledge of the discrete "
          "logarithm x that satisfies g^x = y modulo p, with the order of g "
          "modulo p being q.")
    print("However, I don't want to leak any information about x.")
    print("So, I use a non-interactive zero-knowledge proof for my purpose.")
    print("=================================================================")
    print("Here is my proof: ")
    print("Firstly, I choose a random (secret) v and compute t = g^v in Zq.")
    print("Secondly, I compute c = SHA256(g, y, t).")
    print("Then, I compute r = v - cx modulo q.")
    print("Finally, I will send you my proof (t, r).")
    print("You can check it by determining whether t == g^r * y^c or not.")
    print("Since there's negligible probability that I could forge the value "
          "r, you should believe that I really have knowledge of x.")
    print(g, y, p, q, t, r, sep="\n")
 
 
if __name__ == "__main__":
    if proof_of_work():
        baby_proof()
cs


The key here is that $x$ is quite small compared to $2^{256}$, and $v$ is selected in $[1, x]$. 

Given the printed parameters, we can directly obtain the value of $c$ as well. We see that $$ cx \equiv v - r \pmod{q}, \quad 1 \le v \le x $$ Since $x$'s bit length is $247$, we get $1 \le v \le 2^{247}$ and can write something like $$ cx \pmod{q} \approx -r + 2^{246} \pmod{q} $$ This is an instance of Hidden Number Problem, so just gather a lot of these information and solve it.


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
= 60 ## get 60 instances
= Matrix(ZZ, d+1, d+1)
for i in range(0, d):
    M[0, i] = cs[i]
M[0, d] = 1
for i in range(0, d):
    M[i+1, i] = qs[i]
 
Target = [0* (d+1)
for i in range(0, d):
    Target[i] = (2 ** 246- rs[i]
Target[d] = (2 ** 246)
 
= M.LLL()
GG = M.gram_schmidt()[0]
Target = vector(Target)
TT = Babai_closest_vector(M, GG, Target)
 
= TT[d]
print(x)
print(bytes.fromhex(hex(x)[2:]))
cs


easyRSA?


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
from Crypto.Util.number import *
import numpy as np
 
mark = 3**66
 
def get_random_prime():
    total = 0
    for i in range(5):
        total += mark*** getRandomNBitInteger(32)
    fac = str(factor(total)).split(" * ")
    return int(fac[-1])
 
def get_B(size):
    x = np.random.normal(016, size)
    return np.rint(x)
 
= get_random_prime()
= get_random_prime()
= p * q
= 127
 
flag = b"N1CTF{************************************}"
secret = np.array(list(flag))
 
upper = 152989197224467
= np.random.randint(281474976710655, size=(e, 43))
= get_B(size=e).astype(np.int64)
linear = (A.dot(secret) + B) % upper
 
result = []
for l in linear:
    result.append(pow(l, e, N))
 
print(result)
print(N)
np.save("A.npy", A)
 
cs


Looking at the problem, we see that we need to do the following

  • Factorize $N$ using the vulnerable random prime generator, and recover the array "linear"
  • Solve the instance of LWE by using the fact that secret vector (the flag) is small as well

We first focus on the first part. I actually thought about coppersmith method, but I couldn't get it to work.

I already have an experience in wasting time with coppersmith approach, (sharsable) so I stopped attempting.


rbtree noted that there is a polynomial $f$ with small coefficients, degree 8, and $f(3^{66}) \equiv 0 \pmod{N}$.

This is because for each $p, q$, there's a degree 4 polynomial with small coefficients that vanishes at $3^{66}$ modulo that prime.

If we multiply the two degree 4 polynomials, we arrive at the described polynomial of degree 8.

He then suggested using a lattice to find this polynomial. This was an excellent idea!


Since we want a small-coefficient-linear-combination of $3^{66 \cdot 0}, 3^{66 \cdot 1}, \cdots , 3^{66 \cdot 8}$ that vanishes to zero, we must use scaling, as I did in sharsable. Check the code for technical details. As I do usually, I used Babai's closest vector algorithm. We can expect $f$ to be factorized into two polynomials of degree 4. By calculating each polynomial at $3^{66}$ and taking GCDs, we can retrieve $p, q$. This completes Part 1. 


For Part 2, we need to solve the LWE problem. This is hard in general, but we know that the secret vector is small as well. 

Of course, LWE problem can be modeled as CVP problem, and we use the "secret vector is small" fact here as well.


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
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
## Step 1 : Factorization of N
rat = 2 ** 1000 
## scaling : super large to force zero in the first column
 
for i in range(09):
    M[i, 0= (3 ** (66 * i)) * rat
M[90= n * rat
for i in range(09):
    M[i, i+1= 1
 
Target = [0* 10
for i in range(110):
    Target[i] = (2 ** 64)
 
= M.LLL()
GG = M.gram_schmidt()[0]
Target = vector(Target)
TT = Babai_closest_vector(M, GG, Target)
 
P.<x> = PolynomialRing(ZZ)
= 0
for i in range(110):
    f = f + TT[i] * x^(i-1)
print(f.factor())
## (2187594805*x^4 + 2330453070*x^3 + 2454571743*x^2 + 2172951063*x + 3997404950) 
## (3053645990*x^4 + 3025986779*x^3 + 2956649421*x^2 + 3181401791*x + 4085160459)
 
cc = 0
cc += 2187594805 * (3 ** (66 * 4))
cc += 2330453070 * (3 ** (66 * 3))
cc += 2454571743 * (3 ** (66 * 2))
cc += 2172951063 * (3 ** (66 * 1))
cc += 3997404950 * (3 ** (66 * 0))
 
= gcd(cc, n)
print(p)
print(n // p)
print(n % p)
 
## Step 2 : housekeeping stuff
## res in res.txt, A in A.npy
= 122286683590821384708927559261006610931573935494533014267913695701452160518376584698853935842772049170451497
= 268599801432887942388349567231788231269064717981088022136662922349190872076740737541006100017108181256486533
= 127
= p * q
phi = (p-1* (q-1)
= inverse(e, phi)
 
cv = []
for x in res:
    cv.append(pow(x, d, n))
 
print(cv)
 
np.set_printoptions(threshold=sys.maxsize)
= np.load("A.npy")
= np.ndarray.tolist(A)
print(A)
 
## Step 3 : LWE with CVP
mod = 152989197224467
 
sel = 15 ## sel can be large as 127, but that's too slow
= Matrix(ZZ, sel + 43, sel + 43)
for i in range(043):
    for j in range(0, sel):
        M[i, j] = A[j][i]
    M[i, sel + i] = 1
for i in range(4343+sel):
    M[i, i-43= mod
Target = [0* (sel + 43)
for i in range(0, sel):
    Target[i] = cv[i] - 8
for i in range(sel, sel + 43):
    Target[i] = 80 ## printable
 
Target = vector(Target)
= M.LLL()
GG = M.gram_schmidt()[0]
Target = vector(Target)
TT = Babai_closest_vector(M, GG, Target)
 
print(TT)
 
res = ""
for i in range(sel, sel+43):
    res += chr(TT[i])
 
print(res)
 
cs


'CTF' 카테고리의 다른 글

TetCTF 2021 Crypto Writeups  (1) 2021.01.03
PBCTF 2020 Crypto Writeups  (1) 2020.12.07
SECCON 2020 OnlineCTF Crypto Write-Ups  (0) 2020.10.11
CryptoHack All Solve  (3) 2020.09.30
TokyoWesternCTF 2020 Crypto Write-Ups  (2) 2020.09.20

I participated in SECCON 2020 Online CTF as a member of HangulSarang. We got 1st place :)

Hangul Day is also my birthday, so that's something I guess :D


The competition collided with ACM-ICPC quals, so I had to start solving at like 7PM. (4 hours late)


During that 4 hours, my teammate solved "This is RSA", which is solved using that each byte can be only 0x30 to 0x39. After that, we can compute solutions in $\pmod{2^k}$ and move upwards using recursion. It was the easiest crypto problem in this CTF, (and the solver wasn't me) so I think I don't need to explain further. The other four crypto problems were quite good, challenging, and very enjoyable, so I will describe my thought process as well. Huge thanks to the problem authors for creating these challenges. :D


koharu


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
while True:
    p = random_prime(1<<64)
    if is_prime((p+1// 2):
        break
 
with open("flag.txt""rb"as f:
    flag = f.read()
flag = int.from_bytes(flag, "big")
 
 
PR.<x> = PolynomialRing(GF(p))
while True:
    P = PR.random_element(degree=64)
    if P.is_irreducible():
        break
 
while True:
    Q = PR.random_element(degree=64)
    if Q.is_irreducible():
        break
 
NP = p**P.degree()
NQ = p**Q.degree()
 
while True:
    R = PR.random_element(degree=64)
    if power_mod(R, (NP-1)//2, P) != 1 and power_mod(R, (NQ-1)//2, Q) != 1:
        break
 
PQ = P*Q
= []
while flag:
    S = PR.random_element(degree=64)
    if flag & 1:
        c.append((S * S) % PQ)
    else:
        c.append((S * S * R) % PQ)
    flag = flag >> 1
 
print("p =", p)
print("PQ =", PQ)
print("R =", R)
print("c =", c)
 
 
cs


The code screams "quadratic residue", and it's similar to bitcrypto in InterKosenCTF. (write-up in this blog)

The only difference is that we are not using large primes, but polynomials in $GF(p)[x]$. This is already weak. 

The reason is that we can easily factorize polynomials in $GF(p)$. We can ask Sage to do it for us :)

Now we can determine whether a given polynomial is a quadratic residue or not. Combine this to get the flag.


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
= 4832823609987476353
F.<x> = PolynomialRing(GF(p))
PQ = 2475361839038406994*x^128 + 1816580044636445865*x^127 + 771106714052997910*x^126 + 2532248969060743840*x^125 + 157159147928168793*x^124 + 1165294508775017303*x^123 + 54498477947855453*x^122 + 564670281176250610*x^121 + 4686383084102262935*x^120 + 4798143559496813901*x^119 + 2373759188753852032*x^118 + 3458843219210551923*x^117 + 3389173528515223367*x^116 + 3175114023644661971*x^115 + 2668820643276713526*x^114 + 1644657084961816584*x^113 + 1949973045428555331*x^112 + 2314884799372359978*x^111 + 1614909032209480656*x^110 + 3706101120120959039*x^109 + 1443476119293487220*x^108 + 507539962924420368*x^107 + 2851578707595377440*x^106 + 2660707099322090529*x^105 + 2275120831055073492*x^104 + 4642644673121099806*x^103 + 780741129747777966*x^102 + 3824963851609159359*x^101 + 1445016816241934269*x^100 + 4706494165496469049*x^99 + 91460120231848540*x^98 + 2033361932245472629*x^97 + 4657205830657809352*x^96 + 627579987075662316*x^95 + 2638155163726745709*x^94 + 773248040814209977*x^93 + 4426134463977473378*x^92 + 1748835523159978170*x^91 + 2545886874835388035*x^90 + 4318027045196127783*x^89 + 529092995613843935*x^88 + 37621695756851259*x^87 + 724317479549357114*x^86 + 235872728824864204*x^85 + 1409136599403563059*x^84 + 984842291673572708*x^83 + 1000642979551429427*x^82 + 2599952022893048437*x^81 + 33489199855748196*x^80 + 2138571356326295553*x^79 + 357904099457660261*x^78 + 1388605866466399741*x^77 + 2123614714168365349*x^76 + 1296407111118101425*x^75 + 3175149128196009486*x^74 + 4407671566428651830*x^73 + 3653949472018283742*x^72 + 2150666969917189331*x^71 + 2425834809198809729*x^70 + 202017664024051124*x^69 + 4656859267960293209*x^68 + 95544718007904685*x^67 + 551963924883187932*x^66 + 1220133766833256737*x^65 + 418789913385574936*x^64 + 3140425594489130574*x^63 + 653426727346469624*x^62 + 2168508737790275670*x^61 + 1350675684196344669*x^60 + 86970043713584944*x^59 + 3125122442296761190*x^58 + 1691082709013935740*x^57 + 14954357710735056*x^56 + 1951640599446313225*x^55 + 3057759244385615044*x^54 + 2842299299534580663*x^53 + 60118912044101305*x^52 + 3791459205438092561*x^51 + 3961025931327708139*x^50 + 3352223936735193809*x^49 + 458087980170556413*x^48 + 303065746752057039*x^47 + 270269323703788403*x^46 + 3435561048914221019*x^45 + 244980776425782882*x^44 + 1756735569264346021*x^43 + 1049402079460555244*x^42 + 1181023304135761892*x^41 + 2480814159047994100*x^40 + 3359295278584507081*x^39 + 1031815312165038169*x^38 + 2284789340145013050*x^37 + 2507227047920435897*x^36 + 4212274843760760739*x^35 + 1874163516348469998*x^34 + 4184876619139253979*x^33 + 2454055493008310058*x^32 + 4810631595605704078*x^31 + 2705618732956794205*x^30 + 4588422028499215564*x^29 + 1362947071518584749*x^28 + 200625668549982104*x^27 + 4162225127389871946*x^26 + 3671964574429446847*x^25 + 497776717675475749*x^24 + 3171362364421276926*x^23 + 4040585504650270495*x^22 + 55143980688943936*x^21 + 1680279432641096886*x^20 + 1141249890787830167*x^19 + 1632171956841566025*x^18 + 4489792289887403690*x^17 + 72863318133800422*x^16 + 3512973315964270180*x^15 + 1880837549990432714*x^14 + 629108155937185931*x^13 + 605563550674482475*x^12 + 3125052390516629852*x^11 + 3434353753938817079*x^10 + 2199180089161294937*x^9 + 4128993677150612079*x^8 + 875038461592559534*x^7 + 1344699457303227348*x^6 + 3605318452000064928*x^5 + 1825112182884559504*x^4 + 4214849563830404245*x^3 + 3018789469914511583*x^2 + 4256870332540451928*+ 3478109193918270445
= 10529800129354981*x^64 + 4658846300069202283*x^63 + 1343603688498785880*x^62 + 77535778799313918*x^61 + 3909004297055292936*x^60 + 1574062357470841720*x^59 + 2255026177942473610*x^58 + 2913895405335010190*x^57 + 910153010204378491*x^56 + 4823161627331431259*x^55 + 4314926186108070132*x^54 + 3776194104903441585*x^53 + 4218241384907734159*x^52 + 2928099962473177675*x^51 + 3620663369166129209*x^50 + 4671199329340054093*x^49 + 2953252709684913819*x^48 + 1470028746745533363*x^47 + 393509208258687360*x^46 + 2631641671658679748*x^45 + 4823463900549231672*x^44 + 22025139085889956*x^43 + 3905072220448754367*x^42 + 3525611426409694274*x^41 + 1087703571442464513*x^40 + 983613039355879671*x^39 + 2292836760450398296*x^38 + 2429042383184252432*x^37 + 4241866215562144008*x^36 + 3567456235250802214*x^35 + 289826756486726727*x^34 + 3070079221437908111*x^33 + 3164478508626375897*x^32 + 4028195041942471423*x^31 + 1611744044712776226*x^30 + 682031605725048858*x^29 + 2334009162012075842*x^28 + 1056698946696323305*x^27 + 1193918408929283326*x^26 + 1546583097398597126*x^25 + 632624061599387394*x^24 + 3924194912006864689*x^23 + 836241738980292724*x^22 + 2019639656826418643*x^21 + 646182266409329495*x^20 + 3568811299250961381*x^19 + 4024124722170180214*x^18 + 2765626713849083593*x^17 + 830125243533734584*x^16 + 3773807917205041413*x^15 + 4579071273569219071*x^14 + 4169012455774239610*x^13 + 2779202281389813792*x^12 + 1668767138196611027*x^11 + 3668902156196312613*x^10 + 2118966174503976203*x^9 + 2876683474352545557*x^8 + 4749450906737437136*x^7 + 2048549559963146669*x^6 + 2337906091414592304*x^5 + 3234395871197583532*x^4 + 624006023034932764*x^3 + 1020142386943254010*x^2 + 4346889740151908150*+ 2337193413394346074
= []
= (x^64 + 2705838326093066801*x^63 + 1861763125820805142*x^62 + 1919270169024731361*x^61 + 728192979251886197*x^60 + 3703504742135431297*x^59 + 608310330267197202*x^58 + 677522369546315305*x^57 + 45111914222503868*x^56 + 3231090245423531905*x^55 + 4439626063971680541*x^54 + 264779255326565930*x^53 + 943573327092647824*x^52 + 3642035360519473519*x^51 + 4624797912514728904*x^50 + 815168423497123035*x^49 + 2058290770523809000*x^48 + 4368972367338353614*x^47 + 1102710837251449034*x^46 + 1838631000574578462*x^45 + 1550208773716319692*x^44 + 4479635398032603580*x^43 + 2547505501081696879*x^42 + 4733577241261296757*x^41 + 1459044726889718801*x^40 + 4736670792998507780*x^39 + 3481084975759672453*x^38 + 4491590348438475003*x^37 + 4286960290474469508*x^36 + 2519824328645383346*x^35 + 722570560813334776*x^34 + 3203376079187925593*x^33 + 2137713042365333594*x^32 + 2529680584881125743*x^31 + 881878615185959251*x^30 + 2648895700342509353*x^29 + 3093613170934869890*x^28 + 1839149659686122740*x^27 + 901352037355979824*x^26 + 3079388294575162468*x^25 + 4316897640303347156*x^24 + 3768144827267250554*x^23 + 1585476600468626452*x^22 + 2408180731465025131*x^21 + 2754322334879778466*x^20 + 1965864600205111832*x^19 + 3016989393277154199*x^18 + 993850365653028982*x^17 + 1661221355151932055*x^16 + 2141520480611688809*x^15 + 636670112723307258*x^14 + 1200822100799196786*x^13 + 2223563845526420680*x^12 + 3134534498746508642*x^11 + 1820327632682349699*x^10 + 4628418849122802568*x^9 + 3731553570235638636*x^8 + 1636534607043587796*x^7 + 1007966122754856335*x^6 + 3571611463638839115*x^5 + 4733247188903796455*x^4 + 3512981852602786831*x^3 + 1560667366459827025*x^2 + 1113839338158290233*+ 4011393002849553527)
= (x^64 + 2776622066009961678*x^63 + 1020248994272362724*x^62 + 1812889731002797017*x^61 + 3946133096396475132*x^60 + 1064362775780462120*x^59 + 4267166204741846229*x^58 + 4461168980925876722*x^57 + 3701193932757315736*x^56 + 4004259984657770019*x^55 + 2566923830139634808*x^54 + 2958380329303059106*x^53 + 4642913814072279374*x^52 + 713990683265973444*x^51 + 2282781718594249732*x^50 + 1691679008052617295*x^49 + 4723620313305465430*x^48 + 4052669689859242595*x^47 + 4607757741831461143*x^46 + 3048879536065529044*x^45 + 2012013680568798151*x^44 + 2125237235418450484*x^43 + 2622384625077739224*x^42 + 710661875195936255*x^41 + 375897308743404378*x^40 + 3253268532586707019*x^39 + 3759767504239334681*x^38 + 2945194932005180334*x^37 + 1316716161821289054*x^36 + 2210075866459201344*x^35 + 3421886933443572088*x^34 + 2124192011313760002*x^33 + 3183242335232871177*x^32 + 4722704310996441203*x^31 + 1640862527872462873*x^30 + 292078618156889354*x^29 + 3970255331239899451*x^28 + 290424178543927660*x^27 + 3979382049081683506*x^26 + 3341058157535181184*x^25 + 1891458780676141416*x^24 + 4585931142037966308*x^23 + 2621586816910493860*x^22 + 4526407296014662985*x^21 + 3345825075365423903*x^20 + 433595205227433076*x^19 + 3510443356995660854*x^18 + 1469161865274264871*x^17 + 1968552305256496645*x^16 + 1902262417167822976*x^15 + 3211385257470450715*x^14 + 259183745852362935*x^13 + 1368548986536267534*x^12 + 3726482530039832086*x^11 + 1196244075361051439*x^10 + 3346319329141804238*x^9 + 2362535635162047034*x^8 + 2131037938034625812*x^7 + 3970887869581347678*x^6 + 4428522899784697485*x^5 + 2482987898184812388*x^4 + 3180131420672415636*x^3 + 4690602932003451909*x^2 + 2572790493146370264*+ 802891458181310745)
cv = (p ** 64 - 1// 2
fin = 0
add = 1
for ply in c:
    Pv = power_mod(ply, cv, P)
    Qv = power_mod(ply, cv, Q)
    if Pv == 1 and Qv == 1:
        fin += add
    add = add * 2
print(fin) ## long to bytes here
cs


urara


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
from flag import flag
 
= random_prime(1 << 1024)
= random_prime(1 << 1024)
= p * q
 
print("n =", n)
 
# ---
 
= int.from_bytes(flag, "big")
= randint(0, n-1)
 
= randint(0, n-1)
= (y^2 - (x^3 + a*x)) % n
 
EC = EllipticCurve(Zmod(n), [a, b])
 
= EC((x, y))
= 2 * P
 
print("a, b =", [a, b])
print("Q =", Q.xy())
 
# ---
 
= int.from_bytes(flag, "big")
= randint(0, n-1)
 
= power_mod(m + t, 65537, n)
print("t =", t)
print("c =", c)
 
cs


Very concise problem. We are given $t, e, n$ and $(m+t)^e \pmod{n}$, and a point is double the point with [$x$-coordinate equal to $m$] [which lies on the elliptic curve $y^2 = x^3 + ax + b$]. I broke that sentence up into pieces because it's pretty long and confusing. So anyways, doing actually elliptic curve stuff seems nearly impossible with the lack of hints we have, and we have a huge hint in $(m+t)^e \pmod{n}$. If we have another polynomial equation about $m$ in $\mathbb{Z}_n[x]$, we can solve this problem using polynomial GCD. (Franklin-Reiter related message attack, like padrsa in InterKosen CTF) Of course, we do have another polynomial equation! Directly using the doubling formula, we have $$ (3m^2 + a)^2 - (2m + Q_x)(4(m^3 + am+b)) \equiv 0 \pmod{n}$$ which is the type of equation we want. Write polynomial GCD, and we can easily find $m$. Again, note that if we encounter a problem while performing standard Euclidean Algorithm, it's because the leading coefficient has a non-trivial GCD with $n$. In this case, we can just factorize $n$ to solve the problem. I was stuck in sharsable for so long before solving this one, so I got some energy from it :D 


For the implementation of polynomial GCD, refer to the write-up of the problem padrsa


1
2
3
4
5
6
= Zmod(n)
P.<x> = PolynomialRing(K, implementation='NTL')
= (3 * x^2 + a)^2 - (2*+ Qx) *(4*(x^3 + a*x+b))
= power_mod(x + t, 65537, f) - c
print(GCD(f, g, n))
## the remaining details are trivial
cs


sharsable


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
from Crypto.Util.number import getPrime, GCD
from flag import FLAG
import random
 
def egcd(a, b):
    r0, r1 = a, b
    s0, s1 = 10
    t0, t1 = 01
    while r1 > 0:
        q = r0 // r1
        r0, r1 = r1, r0 % r1
        s0, s1 = s1, s0 - q * s1
        t0, t1 = t1, t0 - q * t1
    return s0, t0
 
def generateKey():
    p = getPrime(512)
    q = getPrime(512)
    n = p * q
    phi = (p-1)*(q-1)
 
    while True:
        d1 = getPrime(int(n.bit_length()*0.16))
        e1 = random.randint(1, phi)
        ed1 = e1 * d1 % phi
 
        d2 = getPrime(int(n.bit_length()*0.16))
        e2, k = egcd(d2, phi)
        e2 = e2 * (phi + 1 - ed1) % phi
        ed2 = e2 * d2 % phi
 
        if GCD(e1, e2) > 10:
            break
 
    assert((ed1 + ed2) % phi == 1)
 
    return (n, (e1, d1), (e2, d2))
 
n, A, B = generateKey()
= int.from_bytes(FLAG, 'big')
C1 = pow(M, A[0], n)
C2 = pow(M, B[0], n)
assert(pow(C1, A[1], n) * pow(C2, B[1], n) % n == M)
 
import json
print(json.dumps({
    "n": n,
    "A": (A[0], C1),
    "B": (B[0], C2),
    #"d": (A[1], B[1]), # for debug
    }))
 
cs


The code looks convoluted, but it can be compressed (without information loss) into the following results:

  • $d_1, d_2$ are quite small, around $n^{0.16}$. 
  • $e_1d_1 + e_2d_2 \equiv 1 \pmod{\phi(n)}$.
  • We know $e_1, e_2, n$, but we can't use common modulus attack due to $\text{gcd}(e_1, e_2) = 11$.

I didn't even realize we could use common modulus attack here, but that was okay since it doesn't give us useful information anyway. The key clearly lies in the small $d_1, d_2$. At first, my thought was headed to the Wiener's Attack, which I think is justifiable because that attack works with small $d$ as well. Of course, it's hard (if not impossible) to use the continued fraction idea directly. After reading up on some generalizations of Wiener's Attacks, I thought this problem was related to the coppersmith attack. I tried weird stuff like 3-variable coppersmith but it all failed pretty badly. That led to me solving this problem the last out of the four I solved. The idea of the last problem helped me :)


We begin by writing (note that this kind of manipulation is done in Wiener's Attack as well) $$e_1 d_1 + e_2 d_2 = k \phi(n) + 1 = k(n-p-q+1) + 1 \equiv -k(p+q-1) + 1 \pmod{n}$$ We may now note that $k$ is probably around $n^{0.16}$ as well, so the RHS is around $-n^{0.66}$ multiplied by some constant. 


Okay, so we want $d_1, d_2$ to be around $n^{0.16}$, and $e_1d_1 + e_2d_2 \pmod{n}$ to be around $-n^{0.66} \cdot c$ for some "reasonable" constant $c$. Let's first decide the range of $c$ we will look for. We can expect $k$ to be $0.4 \cdot n^{0.16}$ to $2 \cdot n^{0.16}$. We can expect $p+q$ to be $2 \cdot n^{0.5}$ to $3/\sqrt{2} \cdot n^{0.5}$. Combining, we can get something like $c \in [0.8, 4]$. Note that I'm using a lot of handwaving. Now we will fix $c$. 


The result? We have goal values for $d_1, d_2, e_1d_1 + e_2d_2 \pmod{n}$. Sounds like CVP to me now. 

Indeed, we can work this as a CVP with the lattice generated by $$ [e_1, 1, 0], \quad [e_2, 0, 1], \quad [n, 0, 0] $$ and making the result vector as close as possible to $$ [-c \cdot n^{0.66}, n^{0.16}, n^{0.16} ] $$ Here's a problem. The scale between the first column and the next two columns are off by $n^{0.5}$. If we run CVP now, it'll probably ignore the $n^{0.16}$ condition in order to get similar values for the first column. At least, that's what I thought :D 


Therefore, we rescale. We will work with the lattice generated by $$  [e_1, n^{0.5}, 0], \quad [e_2, 0, n^{0.5}], \quad [n, 0, 0] $$ and making our result vector as close as possible to $$ [-c \cdot n^{0.66}, n^{0.66}, n^{0.66} ] $$ which sounds more reasonable. If we run the CVP algorithm, (Babai's) we will have our candidate values for $d_1, d_2$. 

If correct, we will have that $e_1d_1 + e_2d_2 - 1$ is a product of $\phi(n)$, so we can finish easily. So, how do we find the right $c$ to use?


Simple. Just try a whole bunch of them. Refer to the code below for details.  


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
def Babai_closest_vector(M, G, target):
        small = target
        for _ in range(1):
            for i in reversed(range(M.nrows())):
                c = ((small * G[i]) / (G[i] * G[i])).round()
                small -=  M[i] * c
        return target - small 
    
n  = 142793817321992828777925840162504083304079023834001118099549928854335392622287928254035247188624975743042449746066633491912316354241339908190889792327014012472372654378644158878787350693992259970146885854641856991605625756536504266728483088687985429310233421251081614258665472164668993082471923690196082829593
e1 = 82815162880874815458042429141267540989513396527359063805652845923737062346339641683097075730151688566721221542188377672708478777831586255213972947470222613130635483227797717393291856129771004300757155687587305350059401683671715424063527610425941387424425367153041852997937972925839362190900175155479532582934
C1 = 108072697038795075732704334514926058617161875495016327352871122917196026504758904760148391499245235850616838765611460630089577948665981247735905622903872682862860306107704253287284051312867625831877418240290183661755993649928399992531008191618616452091127799880839665225093055618092869662205901927957599941568
e2 = 84856171747859965508406237198459622554468224770252249975158471902036102010991476445962577679301719179079633469099994226630172251817358960347828156301869905575867853640850107406452911333646573296923235424617864473580743418995994067645338437540627399276292679100115018844287273293945121023787594592185295794983
C2 = 101960082023987498941061751761131381167414505957511290567652602520714324823481487410890478130601013005035303795327512367595187718926017321227779179404306882163521882309833982882201152721855538832465833869251505131262098978117904455226014402089126682222497271578420753565370375178303927777655414023662528363360
 
rat = int(n ** 0.5)
= Matrix(ZZ, 33)
M[00= e1
M[10= e2
M[20= n
M[01= rat
M[12= rat
 
= []
for i in range(8002450):
    Target = vector([-int(n ** 0.66 * i / 1000) , int(n ** 0.16* rat, int(n ** 0.16* rat])
    M = M.LLL()
    GG = M.gram_schmidt()[0]
    TT = Babai_closest_vector(M, GG, Target)
    d1 = TT[1// rat
    d2 = TT[2// rat
    L.append(e1 * d1 + e2 * d2 - 1)
print(list(set(L))) ## this is c
 
for phi in c:
    d1 = inverse(e1, phi)
    print(long_to_bytes(pow(C1, d1, n)))
    d2 = inverse(e2, phi)
    print(long_to_bytes(pow(C2, d2, n)))
cs

  

crypto01


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
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
from sage.all import *
from flag import flag
from functools import reduce
 
def encrypt(m, e, n):
    n = int(n)
    size = n.bit_length() // 2
    m_low = m & ((1 << size) - 1)
    m_high = (m >> size)
 
    b = (m_low**2 - m_high**3) % n
    EC = EllipticCurve(Zmod(n), [0, b])
 
    return (EC((m_high, m_low)) * e).xy()
 
def decrypt(c, d, n):
    n = int(n)
    size = n.bit_length() // 2
 
    c_high, c_low = c
    b = (c_low**2 - c_high**3) % n
    EC = EllipticCurve(Zmod(n), [0, b])
    m_high, m_low = (EC((c_high, c_low)) * d).xy()
    m_high, m_low = int(m_high), int(m_low)
 
    return (m_high << size) | m_low
 
def gen_prime(size):
    p = random_prime(1 << size)
    while p % 3 != 2:
        p = random_prime(1 << size)
 
    q = random_prime(1 << size)
    while q % 3 != 2:
        q = random_prime(1 << size)
 
    if q > p:
        p, q = q, p
 
    return int(p), int(q)
 
 
 
SIZE = 512
HINTSIZE = 96
= 3
 
flag = int.from_bytes(flag, "big")
assert flag < (1 << SIZE)
 
masks = [randint(1 << (SIZE-1), 1 << SIZE) for _ in range(N)]
masked_flag = reduce(lambda a, b: a ^ b, masks, flag)
 
 
count = 0
ciphertexts = []
while count < N:
    try:
        p, q = gen_prime(SIZE)
        n = p * q
 
        x = random_prime(int(n ** 0.40))
        y = random_prime(int(sqrt(2 * n // (144 * x*x))))
        zbound = -1 * int(round(((p-q) * (n ** 0.25* y) / (3 * (p + q))))
 
        z_ = zbound + ((p + 1)*(q + 1)*- zbound) % x
        e = ((p + 1* (q + 1* y - z_) // x
        d = inverse_mod(e, (p + 1)*(q + 1))
 
        assert (x*y*x*< (2 * n // 144))
        assert (gcd(x, y) == 1)
 
        d = inverse_mod(e, (p+1)*(q+1))
        c = encrypt(masks[count], e, n)
        assert decrypt(c, d, n) == masks[count]
 
        ciphertexts.append({
            "n": n,
            "e": e,
            "c": c,
            "hint": p & ((1<<HINTSIZE)-1)
        })
        count += 1
    except KeyboardInterrupt:
        break
    except (ZeroDivisionError, OverflowError):
        pass
 
 
print("masked_flag = " ,masked_flag)
print("ciphertexts = ", ciphertexts)
 
cs


@diff (pcw) noted that the order of the elliptic curve must be $(p+1)(q+1)$ since $p \equiv q \equiv 2 \pmod{3}$.

This is a cool fact that I didn't know, but it could be easily guessed by the definition of $d$ in the script. 


So, first things first : is this really a elliptic curve challenge? The answer : probably not?

I thought this for a few reasons - I thought the generation of $e$ was quite convoluted so there should be a weakness.

The elliptic curve part looked too clean to have a vulnerability, and it's not even over a field. Hard to do anything, really. 


Therefore, I decided to take a look at how the parameter generation behaves.

First, I look at the sizes of the values. It 's clear that $y$ is small, so $zbound$ and $z$ are also quite small compared to $x$.

This leads to the following suspicion, is $e = \lfloor (p+1)(q+1)y / x \rfloor$? Generating parameters on our own, we see that this is true with a very high probability. This means that we can ignore $zbound$ and $z$ completely now! This is a good progress indeed :) 


This simplifies a lot of things, and we have a good, clean equation to work with. Start with $$ xe  = (p+1)(q+1)y - r \equiv (p+q+1)y - r\pmod{n}$$ with $r < x \approx n^{0.4}$. With $y \approx n^{0.1}$, we see that $(p+q+1)y - r \approx n^{0.6}$. So $x \approx n^{0.4}$ satisfies $xe \pmod{n} < \approx n^{0.6}$. 

Heuristically speaking, there should be a fairly small number of $x$ that satisfy that condition! Can we find them though?


Turns out the answer is yes. I guess you can do this with lattices, but here's a method I learned from competitive programming. 

In fact, given $L, R, M, A$, we can find the minimum nonnegative integer $x$ such that $L \le Ax \pmod{M} \le R$. 

The method is not long, but I don't want to type it all out, so here's a link (scroll down to the end of the editorial)

Also, a problem using this algorithm appeared in NWRRC, (problem G) set by tourist. 


Since we know $e, n$, we can select some $CUT \approx n^{0.6}$ and find the minimum $x$ such that $1 \le ex \pmod{n} \le CUT$. Of course, we have to be cautious with constants when selecting $CUT$. It's not too hard, since we can always verify our value of $x$ by checking its primality and size. This concludes the recovery of $x$, which is a huge progress again! Now we try to recover $y$. 


To recover $y$, we use basic inequalities. Note the following two inequalities $$ y = \frac{xe+r}{(p+1)(q+1)} \ge \frac{xe}{n + 3 \sqrt{n/2} + 1}$$ $$ y = \frac{xe+r}{(p+1)(q+1)} \le \frac{xe + x-1}{n + 2\sqrt{n} + 1}$$ which gives us decent lower/upper bounds. Turns out that this is also perfect, as the two bounds are actually equal! This recovers $y$. 


With this information, we can now bound $p+q$. Simply use $$ p+q = \frac{xe+r}{y} - 1 \ge \frac{xe}{y} - 1 $$ $$ p+q  = \frac{xe+r}{y} -1 \le \frac{xe+x-1}{y} - 1$$ which gives us a good bound for $p+q$. Now we proceed as we did in l33tcrypt in DownUnderCTF and use quadratic formula to get a good bound for $p$. Note that $p > q$. If we analyze the two bounds, we see that this gives us about 200 MSBs of $p$. Now we are practically done. 


We already know 96 LSBs of $p$ via the hint. This adds up to 296 bits of information, so we only need to determine 216 bits of $p$. This can be easily done with coppersmith method, i.e. Sage's small_roots. This part is very straightforward. Now we calculate $p, q, d$ and eventually the masks. The conversion of masks to the actual flag was done by @ironore15. This concludes the problem :D


Also, maybe you can recover $x, y$ with continued fractions on $e/n$? Maybe. I didn't try it :P

UPD : Okay I tried it and it works easily. Why didn't I think about this lmao... This is a much easier way to solve. :)

Basically, from $e = \lfloor (p+1)(q+1)y / x \rfloor$, we can guess that $$ \frac{e}{n} \approx \frac{e}{(p+1)(q+1)} \approx \frac{y}{x}$$ so $y/x$ is a good approximation of $e/n$. Find all convergents of $e/n$, and find those that have primes as its numerator and denominator. 

This gives us $x, y$, so we can proceed with the coppersmith attack as above. Much easier :)


UPD : The continued fraction solution is indeed the intended sol. I guess it could be proved using the given bounds :)

UPD : So apparently this thing had a name, KMOV cryptosystem. didn't know that :P



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
70
71
72
73
74
75
76
77
78
79
def kthp(n, k):
    if n == 0:
        return 0
    lef = 1
    rig = 2
    while rig ** k < n:
        rig = rig << 1
    while lef <= rig:
        mid = (lef + rig) // 2
        if mid ** k <= n:
            best = mid
            lef = mid + 1
        else:
            rig = mid - 1
    return best
 
def ceil(n, m):
    return (n + m - 1// m
 
def optf(A, M, L, R):
    if L == 0:
        return 0
    if 2 * A > M:
        L, R = R, L
        A = M - A
        L = M - L
        R = M - R
    cc_1 = ceil(L, A)
    if A * cc_1 <= R:
        return cc_1
    cc_2 = optf(A - M % A, A, L % A, R % A)
    return ceil(L + M * cc_2, A)
 
def decrypt(c, d, n):
    n = int(n)
    size = n.bit_length() // 2
 
    c_high, c_low = c
    b = (c_low**2 - c_high**3) % n
    EC = EllipticCurve(Zmod(n), [0, b])
    m_high, m_low = (EC((c_high, c_low)) * d).xy()
    m_high, m_low = int(m_high), int(m_low)
 
    return (m_high << size) | m_low
 
ciphertexts =  []
for C in ciphertexts:
    n = C['n']
    e = C['e']
    c = C['c']
    hint = C['hint']
    CUT = kthp( (int)(n ** 0.2// 722* kthp(n, 2* 8
    x = optf(e, n, 1, CUT)
    R = ((e * x + x - 1// (n + 2 * kthp(n, 2+ 1))
    L = ((e * x) // (n + 3 * kthp(n//22+ 1))
    y = (L + R) // 2
    assert L == R and x in Primes() and y in Primes()
    sum_L = (x * e) // y - 1 - n
    sum_R = (x * e + x - 1// y - 1 - n
    lr = (sum_R + kthp(sum_R * sum_R - 4 * n, 2)) // 2
    sm = (sum_L + kthp(sum_L * sum_L - 4 * n, 2)) // 2
    assert sm <= lr
    assert (sm >> 312== (lr >> 312)
    p_hint = hint
    K = Zmod(n)
    P.<t> = PolynomialRing(K, implementation='NTL')
    f = (p_hint * inverse_mod(2 ** 96, n)) % n + t + (2 ** (312-96)) * (lr >> 312)
    x0 = f.small_roots(X = (2 ** 220), beta = 0.5, epsilon = 0.03)
    ## print(x0)
    p = p_hint + x0[0* (2 ** 96+ (2 ** 312* (lr >> 312)
    p = (int)(p)
    q = n // p
    d = inverse_mod(e, (p+1* (q+1))
    print(decrypt(c, d, n))
 
## thanks, ironore!
def recover_flag(masks, masked_flag):
    flag = reduce(lambda a, b: a ^ b, masks, masked_flag)
    return flag.to_bytes(512 // 8'big')
cs


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
def get_red(e, n):
    cur_num, cur_den = e, n
    num_1, den_1 = 01
    num_2, den_2 = 10
    while True:
        val = cur_num // cur_den
        nxt_num = cur_den
        nxt_den = cur_num - val * cur_den
        # calculate new convergent
        num_3 = val * num_2 + num_1
        den_3 = val * den_2 + den_1
        if isPrime(num_3) and isPrime(den_3):
            return num_3, den_3
        if den_3 > int(n ** 0.4):
            return -1
        # update convergents
        num_1, den_1 = num_2, den_2
        num_2, den_2 = num_3, den_3
        # update continued fractions
        cur_num, cur_den = nxt_num, nxt_den
 
ciphertexts =  []
for C in ciphertexts:
    n = C['n']
    e = C['e']
    c = C['c']
    hint = C['hint']
    y, x = get_red(e, n)
    R = ((e * x + x - 1// (n + 2 * kthp(n, 2+ 1))
    L = ((e * x) // (n + 3 * kthp(n//22+ 1))
    assert y == (L + R) // 2
    assert L == R and isPrime(x) and isPrime(y)
    ## continue as the initial solution
cs


'CTF' 카테고리의 다른 글

PBCTF 2020 Crypto Writeups  (1) 2020.12.07
N1CTF 2020 Crypto Write-Ups  (0) 2020.10.18
CryptoHack All Solve  (3) 2020.09.30
TokyoWesternCTF 2020 Crypto Write-Ups  (2) 2020.09.20
DownUnderCTF Crypto Write-Ups  (2) 2020.09.19