LIT CTF 2023: Polypoint

This encryption scheme is mathematically unbreakable if you don’t have all the keys! You learnt so in grade school, right?

We are given two files: encode.py and encoded.txt.

encode.py:

from secrets import SystemRandom

gen = SystemRandom()

with open("flag.txt", "rb") as f:
    flag = f.read().strip()

assert len(flag) == 76
c = int.from_bytes(flag)

poly = [c]
for _ in range(10):
    poly.append(gen.randrange(pow(10, 11), pow(10, 12)))

for _ in range(10):
    x = gen.randrange(pow(10, 11), pow(10, 12))
    v = 1
    y = 0
    for i in range(11):
        y += poly[i] * v
        v *= x
    print(f"({x},{y})")

Let’s analyze this in pieces.

from secrets import SystemRandom

gen = SystemRandom()

This is a cryptographic PRNG, so it should probably be treated as truly random for the purposes of the solution.

with open("flag.txt", "rb") as f:
    flag = f.read().strip()

assert len(flag) == 76
c = int.from_bytes(flag)

This is pretty standard for crypto challenges: read the flag and encode it as an integer. We are also given the extra piece of information that the flag is 76 bytes long, which turns out to be useful later.

poly = [c]
for _ in range(10):
    poly.append(gen.randrange(pow(10, 11), pow(10, 12)))

So it creates a list of eleven numbers, the first being the integer representation of the flag, and the rest being random integers between 101110^{11} and 101210^{12}. I’m going to refer to each of these numbers as p1p_1, p2p_2, and so on up to p11p_{11}, and the whole list as the eleven-dimensional vector p\vec{p}.

for _ in range(10):
    x = gen.randrange(pow(10, 11), pow(10, 12))
    v = 1
    y = 0
    for i in range(11):
        y += poly[i] * v
        v *= x
    print(f"({x},{y})")

One important thing to notice is that nothing is mutated inbetween iterations of the outer loop. This means that we essentially have ten independent trials relating x to y.

Now, let’s look at what each iteration actually does. First, it initializes the variable x to a random integer between 101110^{11} and 101210^{12} (like p2...p11p_2 ... p_{11}); the variable v to one; and the variable y to zero.

Then, it runs a nested loop for eleven iterations. Each iteration first adds pip_{i} times the current value of v to y, then multiplies v by x.

The first thing to notice is that we know the value of v at every iteration of the inner loop. I’ll denote the value of v in the kk-th outer loop iteration (trial) and ii-th inner loop iteration as vk,iv_{k,i}, and the value of x as xkx_k. Then we have:

vk,i=xkiv_{k, i} = x_k^i

y starts at zero, so its final value will just be the sum of poly[i] * v over the whole inner loop. Written in math notation (with yky_k being the final value of y for the kk-th trial):

yk=i=111pivk,iy_k = \sum_{i=1}^{11} p_i v_{k,i}

The key realization is that this is also the dot product between p\vec{p} and vk\vec{v}_k:

yk=pvky_k = \vec{p} \cdot \vec{v}_k

Now we can write this out for all ten trials:

v1p=y1v2p=y2v10p=y10\begin{align*} \vec{v}_1 \cdot \vec{p} &= y_1 \\ \vec{v}_2 \cdot \vec{p} &= y_2 \\ &\vdots \\ \vec{v}_{10} \cdot \vec{p} &= y_{10} \end{align*}

This is just a linear system of equations! Let’s rewrite it as a matrix equation:

[v1,1v1,2v1,11v2,1v2,2v2,11v10,1v10,2v10,11]p=y\begin{bmatrix} v_{1, 1} & v_{1, 2} & \cdots & v_{1, 11} \\ v_{2, 1} & v_{2, 2} & \cdots & v_{2, 11} \\ \vdots & \vdots & \ddots & \vdots \\ v_{10, 1}& v_{10, 2}& \cdots & v_{10, 11}\\ \end{bmatrix} \vec{p} = \vec{y}

We can’t just plug this in to a calculator and solve it though. The system is underconstrained, so we need some way to limit the search to find the unique true solution, p\vec{p}.

First of all, we know that p\vec{p} is made up of all integers (pZ11\vec{p} \in \mathbb{Z}^{11}), so we should only look for integer solutions. We also have some lower and upper bounds for each of the entries of p\vec{p}:

At this point, I reformatted the data as a CSV file and loaded it into Mathematica to do the hard work of actually solving the system for me: