Hat Trick - Alpacahack Round 3

Table of Contents

Challenge overview

Here’s a brief overview of the challenge code:

class RollingHash:
  def __init__(self, p=None, base=None) -> None:
    self.p = getPrime(64) if p is None else p
    self.base = (getRandomInteger(64) if base is None else base) % self.p
  def hash(self, s: str):
    res = 0
    for i, c in enumerate(s):
      res += ord(c) * (self.base ** i)
      res %= self.p
    return res

def check_str(s: str, max_len: int):
  assert len(s) <= max_len, "too long!"
  for i, c in enumerate(s):
    assert c in string.ascii_lowercase, f"found invalid char {c} at {i}"

MAX_LEN = 54

rhs = [RollingHash() for _ in range(3)]
print("params:",json.dumps([{ "base": rh.base, "p": rh.p } for rh in rhs]))

for _ in range(3):
  target_hash = [random.randrange(0, rh.p) for rh in rhs]
  print('target:', target_hash)
  s = input("> ")
  check_str(s, MAX_LEN)

  actual_hash = [rh.hash(s) for rh in rhs]
  if target_hash != actual_hash:
    print("Oops! You missed the target hash. Better luck next time!")
    exit(1)

print("Congratulations! Here is your flag:", flag)

So, we have 3 rounds to pass, in each one the server gives 3 different base and p values. Basically, we need to find a string $s$ of length $\leq 54$ that solves this system of equations:

$$ \left\{ \begin{aligned} \sum_{i=0}^{n} s_i \cdot \text{base}_0^i &\equiv \text{target}_0 \pmod{p_0} \\ \sum_{i=0}^{n} s_i \cdot \text{base}_1^i &\equiv \text{target}_1 \pmod{p_1} \\ \sum_{i=0}^{n} s_i \cdot \text{base}_2^i &\equiv \text{target}_2 \pmod{p_2} \end{aligned} \right. $$

Where each element of $s_i$ is a lowercase letter, meaning $s_i \in [97, 122]$, where 97 represents a and 122 represents z in the ASCII table.

One equation only

Let’s create the solution step by step.

Assume we only need to solve one equation, without any constraint on $s_i$.

Then we have a single equation $\pmod{p}$:

$$ s_0 + s_1 \cdot \text{base} + s_2 \cdot \text{base}^2 + s_3 \cdot \text{base}^3 + \text{...} + s_{53} \cdot \text{base}^{53} \equiv \text{target} \pmod{p}$$

Then we can just select every $s_{i>0}$ randomly and then choose $s_0$ ad-hoc to solve the equation:

$$ s_0 + \text{some\_random\_stuff} \equiv \text{target} \pmod{p}$$

(I’m pretty sure that if you are reading this writeup you are able to solve this by yourself 🫠)

At this point, we are either extremely lucky or, realistically, we have perfectly chosen $s_{i>0}$ with a very bad $s_0$.

Finding small solution

  • Let’s try to find solutions that are $ \in [-10,10]$.
  • For simplicity, assume $\text{target} = 0$.
  • Also, to avoid handling the modulus, we can just add another variable $k$.

With those assumptions, this is the equation that we are trying to solve:

$$ \begin{pmatrix} s_0 & s_1 & \text{...} & s_{53} & k \end{pmatrix} \cdot \begin{pmatrix} \text{base}^0 \\ \text{base}^1 \\ \text{...} \\ \text{base}^{53} \\ -p \end{pmatrix} $$

Where this is a linear combination of $\text{base}^i$ with integer coefficients ($s_i$). This is exactly a lattice! And $\text{LLL}$ finds a small vector in the lattice!

So, to find a solution, we can just run the $\text{LLL}$ algorithm on the column-matrix $\text{base}^i$.

sage: base = 100
sage: p = 101
sage: eqs = Matrix([base^i % p for i in range(5)]).T
sage: M = eqs.stack(vector([p]))
sage: M
[ 1]
[14]
[95]
[17]
[36]
[101]
sage: M.LLL()
[0]
[0]
[0]
[0]
[0]
[1]

We see that there is a non-trivial integral linear combination of these vectors that gets to 0. But we also want to extract the coefficients for it!
To do this, we can construct the lattice:

$$ \begin{pmatrix} eqs & 1 \\ p & 0 \end{pmatrix} $$

that is

$$ \begin{pmatrix} 1 & 1 & 0 & 0 & 0 & 0 \\ 14 & 0 & 1 & 0 & 0 & 0 \\ 95 & 0 & 0 & 1 & 0 & 0 \\ 17 & 0 & 0 & 0 & 1 & 0 \\ 36 & 0 & 0 & 0 & 0 & 1 \\ 101 & 0 & 0 & 0 & 0 & 0 \\ \end{pmatrix} $$
sage: M = block_matrix([
....: [eqs,1],
....: [p,0]])
sage: M
[  1|  1   0   0   0   0]
[ 14|  0   1   0   0   0]
[ 95|  0   0   1   0   0]
[ 17|  0   0   0   1   0]
[ 36|  0   0   0   0   1]
[---+-------------------]
[101|  0   0   0   0   0]
sage: M.LLL()
[ 1  1  0  0  0  0]
[ 1  0  1 -1  1 -1]
[ 2 -1 -1  0  1  0]
[ 1 -1  0  0 -2  1]
[-1  1 -1 -2  0  0]
[ 1 -1  1  0  1  2]

For each row now we have:

  • row[0] is the result of the equation
  • row[1:] are the coefficients

In this case, we haven’t found a solution, but we could combine the first and the second row to get the solution vector:

$$ \begin{pmatrix} 0 & 1 & -1 & 1 & -1 & 1 \end{pmatrix} $$

Why didn’t $\text{LLL}$ directly give us a solution? Because it searches for the shortest vector in the lattice, and right now we’ve added the identity matrix on the right, which has non-null entries!

We can add a big weight $W$ on the ’equation column’ (the first one) so that $\text{LLL}$ will try to minimize it before the others (as in the first one we will have 10-digit numbers while in the other columns we will have only 1s).

sage: M.rescale_col(0,W)
sage: M
[  1000|     1      0      0      0      0]
[ 14000|     0      1      0      0      0]
[ 95000|     0      0      1      0      0]
[ 17000|     0      0      0      1      0]
[ 36000|     0      0      0      0      1]
[------+----------------------------------]
[101000|     0      0      0      0      0]
sage: M.LLL()
[   0   -1    1   -1    1   -1]
[   0   -2    0    0   -2    1]
[   0    0   -2    1    2    0]
[   0   -1    0   -1   -2   -2]
[   0    2   -1   -2    0    0]
[1000    1    0    0    0    0]

This is what we like to see. We can take the first 4 rows of $\text{M.LLL()}$, and any linear combination of them will result in a 0.

sage: vector(M.LLL()[0][1:]) * vector([base^i % p for i in range(5)])
-101
sage: vector(M.LLL()[0][1:]) * vector([base^i % p for i in range(5)]) % p
0

Solving for target

We can’t just directly integrate the target into our equations matrix naively:

sage: target = 28 # random
sage: eqs = eqs.stack(vector([-target]))
[ 1]
[14]
[95]
[17]
[36]
[-28]
sage: M = block_matrix([
....: [eqs,1],
....: [p,0]])
sage: M
[  1|  1   0   0   0   0   0]
[ 14|  0   1   0   0   0   0]
[ 95|  0   0   1   0   0   0]
[ 17|  0   0   0   1   0   0]
[ 36|  0   0   0   0   1   0]
[-28|  0   0   0   0   0   1]
[---+-----------------------]
[101|  0   0   0   0   0   0]
sage: M.rescale_col(0,W)
sage: M.LLL()
[   0    0   -1   -1    0    1    1]
[   0   -1    1   -1    1   -1    0]
[   0    0   -2    0    0    0   -1]
[   0    0   -1    1   -1   -1    1]
[   0    0    0    1    2    0    1]
[   0   -2    0    1    0    1    1]
[1000    1    0    0    0    0    0]

As in this case, $\text{LLL}$ could use the target $n$-times without any problem. In this small example, we got lucky and found a solution that has a coefficient for the target of $1$ (in the last column), but this isn’t always the case.

Again, we can put a weight on the coefficient of the target to tell $\text{LLL}$ to use that as little as possible.

sage: M.rescale_col(6,W)
sage: M
[  1000|     1      0      0      0      0      0]
[ 14000|     0      1      0      0      0      0]
[ 95000|     0      0      1      0      0      0]
[ 17000|     0      0      0      1      0      0]
[ 36000|     0      0      0      0      1      0]
[-28000|     0      0      0      0      0   1000]
[------+-----------------------------------------]
[101000|     0      0      0      0      0      0]
sage: M.LLL()
[   0   -1    1   -1    1   -1    0]
[   0   -2    0    0   -2    1    0]
[   0    0   -2    1    2    0    0]
[   0   -1    0   -1   -2   -2    0]
[   0    2   -1   -2    0    0    0]
[1000    1    0    0    0    0    0]
[   0    0    0    1    2    0 1000]

Dividing by the weight, now we get

[ 0 -1  1 -1  1 -1  0]
[ 0 -2  0  0 -2  1  0]
[ 0  0 -2  1  2  0  0]
[ 0 -1  0 -1 -2 -2  0]
[ 0  2 -1 -2  0  0  0]
[ 1  1  0  0  0  0  0]
[ 0  0  0  1  2  0  1]

Where $M[-1]$ uses the target only once (and is a valid solution), $M[-2]$ can be discarded (as it doesn’t solve the equation), and so we have all the possible solutions defined by:

$$ M[-1] + a_0 \cdot M[0] + a_1 \cdot M[1]+ a_2 \cdot M[2]+ a_3 \cdot M[3]+ a_4 \cdot M[4] $$

Closest Vector Problem

At this point, we are basically done. Now, instead of having a short vector, we want a vector that is as close as possible to:

$$ \begin{pmatrix} 0 & 110 & 110 & 110 & 110 & 110 & 1 \end{pmatrix} $$

Where the first 0 is the result of the equation, the 110 is the average value of ASCII lowercase letters, and the last 1 is the coefficient of the target.

Sage CVP

To do this, we could take some implementation of CVP online (like Babai) or use Sage’s implementation.

sage: L = IntegerLattice(M, lll_reduce=False)
sage: L
Free module of degree 7 and rank 7 over Integer Ring
User basis matrix:
[ 0 -1  1 -1  1 -1  0]
[ 0 -2  0  0 -2  1  0]
[ 0  0 -2  1  2  0  0]
[ 0 -1  0 -1 -2 -2  0]
[ 0  2 -1 -2  0  0  0]
[ 1  1  0  0  0  0  0]
[ 0  0  0  1  2  0  1]
sage: target_v = vector([0,110,110,110,110,110,1])
sage: L.approximate_closest_vector(target_v)
(0, 110, 109, 109, 109, 110, 1)

(now the closest vector is a solution to the equation bounded as ASCII lowercase letters)

Three equations

By now, you should have an intuition on how to solve the complete case. Our eqs matrix will have 3 columns instead of 1, with 3 different moduli variables.

The problem is now:

$$ \begin{pmatrix} s_0 & s_1 & \text{...} & s_{53} & t_0 & t_1 & t_2 & k_0 & k_1 & k_2 \end{pmatrix} \cdot \begin{pmatrix} \text{base}_0^0 & \text{base}_1^0 & \text{base}_2^0 \\ \text{base}_0^1 & \text{base}_1^1 & \text{base}_2^1 \\ \text{...} & \text{...} & \text{...} \\ \text{base}_0^{53} & \text{base}_1^{53} & \text{base}_2^{53} \\ -target_0 & 0 & 0 \\ 0 & -target_1 & 0 \\ 0 & 0 & -target_2 \\ p_0 & 0 & 0 \\ 0 & p_1 & 0 \\ 0 & 0 & p_2 \end{pmatrix} $$

Where $s_i$ must be as close as possible to $110$, $t_i$ must be $1$, and $k_i$ can be an arbitrary integer.

LLL

Like above, assuming eqs is the 3-column matrix on the right, we can reduce this lattice:

$$ \begin{pmatrix} \text{eqs}\cdot W & 1 \\ \end{pmatrix} $$

We discard the last 3 columns (as they are the coefficients of the moduli) and from here, apply CVP to:

$$ \begin{pmatrix} 0 & 0 & 0 & \underbrace{110 \quad 110 \quad \dots \quad 110}_{\text{54 times}} \end{pmatrix} $$

Complete code

import colored_traceback
colored_traceback.add_hook = lambda: None
import string
from pwn import remote, process
import json
# r = remote('34.170.146.252',47001)
r = process(['python3', 'server.py'])
r.recvuntil(b'params: ')
params = json.loads(r.recvline())


def solve_equations(params, target):
    for LEN in range(48,55):
        print(LEN)
        P = PolynomialRing(ZZ, ','.join([f'x{i}' for i in range(LEN)]))
        xs = P.gens()
        eqs = []
        for param,tt in zip(params,target):
            base = param['base']
            p = param['p']
            eq = sum([x * (pow(base,i,p)) for i, x in enumerate(xs)]) - tt
            eqs.append(eq)
        M,mons = Sequence(eqs).coefficient_matrix(sparse=False)
        M = block_matrix([
            [M.T, 1],
            ])
        # add below p0,0; p1,0; p2,0
        M = M.stack(vector([params[0]['p']] + [0] * (LEN + 3)))
        M = M.stack(vector([0]*1 + [params[1]['p']] + [0] * (LEN + 2)))
        M = M.stack(vector([0]*2 + [params[2]['p']] + [0] * (LEN + 1)))
        ww = [1] * 3+ [128] * LEN + [1] 
        ws = diagonal_matrix([max(ww) // w for w in ww])
        M = M * ws
        M = M.LLL()
        M = M / ws
        M = M.change_ring(ZZ)
        # now solve CVP to vector [0,0,0,1] + [61]*LEN
        target_v = vector([0,0,0] + [109]*LEN + [1])

        def Babai_closest_vector(M, target):
            # Babai's Nearest Plane algorithm
            G = M.gram_schmidt()[0]
            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

        closestvector = Babai_closest_vector(M, target_v)
        print(closestvector)
        # this should solve all equations
        for i,eq in enumerate(eqs):
            print(eq(*closestvector[3:-1]) % params[i]['p'])
        
        if all([0<x<128 for x in closestvector[3:-1]]):
            s = ''.join([chr(int(x)) for x in closestvector[3:-1]])
        else:
            s = 'failed\x01'
        print(s,LEN)
        # if all ascii lowercase
        if all([c in string.ascii_lowercase for c in s]):
            return s

for _ in range(3):
    r.recvuntil(b'target: ')
    target = json.loads(r.recvline())
    s = solve_equations(params, target)
    r.sendline(s)
r.interactive()

# Alpaca{i_st1ll_h4v3_n0_id3a_why_r0ll1ng_h4sh_is_c4ll3d_th4t}