---
# System prepended metadata

title: shuffle-revenge
tags: [LACTF, Cryptography, LCG, Lattice, PRNG]

---

# shuffle-revenge

[LA CTF 2025](https://platform.lac.tf/) - Crypto

Cracking an LCG-based shuffling algorithm using lattices

Challenge author writeup by [Arnav Vora](https://github.com/AVDestroyer)

> Casino grade shuffling: use this to always WIN WIN WIN!!!

## Challenge and solve

This challenge uses an LCG to generate sorting indices for a "random" shuffle.

```py
def shuffle_msg(msg: bytes, L: LCG) -> str:

    l = len(msg)

    positions = [L.next() for i in range(l)]

    sorted_msg = sorted(zip(positions, msg))

    output = bytes([c[1] for c in sorted_msg])

    return binascii.hexlify(output).decode()
```

Refer to my previous [writeup](https://hackmd.io/@Arnav-Vora/rJ60YkPQkx) on any%-ssg last year for the basics of LCG seedcracking using lattices.

We are allowed to send up to 256 bytes to be shuffled, and we also received a shuffled secret. Our goal is to unshuffle the secret which gives us the flag. We are also given the LCG parameters, so we just need to find the LCG seed used to create the shuffle.

This writeup will mainly focus on how I created the bounds for the LCG outputs. I can send 256 bytes to be shuffled and observe the output, which tells us the ordering of 256 consecutive LCG outputs. Suppose that byte $i$ is shuffled to position $p_i$. Also suppose that the LCG starts at seed $s$ and $LCG_k(s)$ is the LCG applied $k$ times to $s$. Suppose that $p_i > p_j$, then $LCG_i(s) > LCG_j(s)$.

This allows us to write 255 inequalities on LCG outputs from $LCG_1$ to $LCG_{256}$ (for example, $LCG_{192} < LCG_7 < LCG_{242} < ...$ could be some ordering).

The size of the seed is 64 bits, so we need to write some constraints on the seed that give 64 bits of information total. Also, the solution method ends up being exponential in the number of contraints, so we should probably not use more than 16 constraints. 16 constraints would mean that each constraint needs to provide 4 bits of information for 64 bits total. How can we write these constraints?

From testing, I found that you can divide the range of possible seeds into 16 ranges, and choose LCG outputs that are likely to fall into those ranges. For example, the smallest LCG output is likely in the first of the 16 ranges, the 16th LCG output is likely in the second of the 16 ranges, etc. With some simulations, I found that the likelihood of all 16 of these conditions being true is around 20% if we shuffle 256 bytes total, so this is a viable path.

So, to solve the challenge, I send 256 bytes to be shuffled, recover the ordering, and create constraints based on the aforementioned guessing of seed ranges based on the ordering of LCG outputs. Then, I plug these ranges into my lattice solution method, find a seed that satisfies the conditions, and step backward 64 seeds to recover the seed using to shuffle the _secret_. Then, I simply unshuffle the secret and get the flag.

I ran 15 instances of `solve.sage` in parallel and searched for a flag output in any of them.

Also, my strategy of brute forcing points around the midpoint was not entirely working for this challenge (not sure why). However, the endpoints of the transformed parallellopiped were very close to each other so I simply brute forced all possible points in between these endpoints (in a cube)

## Solve script(s)

### Bash

```bash
#!/bin/bash
rm -rf solve_runs
mkdir solve_runs

for i in {1..15}; do
  (
    sage solve.sage ${1:-} 2>&1 | tee solve_runs/output_$i.log | grep -E "lactf{.+}"
  ) &
done
wait
```

### Sage

```py
import os

os.environ["TERM"] = "xterm-256color"
from pwn import *
import binascii
from itertools import product
import base64


def conn():
    if args.LOCAL:
        r = process(["python3", "shuffler.py"])
        if args.DEBUG:
            gdb.attach(r)
    else:
        r = remote("chall.lac.tf", int(31172))

    return r


def lcg(a, c, m, seed):
    return (a * seed + c) % m


def inv_lcg(a, c, m, seed):
    return ((seed - c) * pow(a, -1, m)) % m


def unsort(params, current_seed, sorted_msg: bytes, length):
    nums = []
    a, c, m = params
    seed = current_seed
    for i in range(length):
        nums.append((seed, i))
        seed = lcg(a, c, m, seed)

    sorted_nums = sorted(nums)

    orig = [0] * length
    for i in range(length):
        orig[sorted_nums[i][1]] = sorted_msg[i]

    return bytes(orig)


def main():

    # test alphabet
    test = bytes([i for i in range(256)])

    r = conn()

    r.recvline()
    a = int(r.recvline().decode().strip().split("=")[1])
    c = int(r.recvline().decode().strip().split("=")[1])
    m = int(r.recvline().decode().strip().split("=")[1])
    print(f"a={a}\nc={c}\nm={m}")

    r.sendlineafter(b"> ", b"2")
    shuffled = binascii.unhexlify(r.recvline().decode().strip().split()[-1].encode())

    r.sendlineafter(b"> ", b"1")
    r.recvline()
    r.sendline(binascii.hexlify(test))
    response = binascii.unhexlify(r.recvline().decode().strip()[13:].encode())

    # figure out shuffling

    selections = []
    for i in range(0, 256, 16):
        selections.append((response[i], test.index(response[i])))

    # create bounds
    # x vector: [seed0, one, k1, k2, .. k16]
    mins = [0, (1 << 64) / 16]
    maxs = [(1 << 64) / 16, (1 << 64) / 16]
    fail = False

    dim = 18

    a_helper = [[0] * dim for j in range(dim)]
    a_helper[0][0] = 1 / 16
    a_helper[1][1] = (1 << 64) / 16

    for i, (_, index) in enumerate(selections):
        lb = max(0, (i * 16) / 256 * (1 << 64) - (1 << 64) / 32)
        ub = min(1 << 64, (i * 16) / 256 * (1 << 64) + (1 << 64) / 32)

        a_helper[2 + i][2 + i] = m
        a_helper[2 + i][0] = pow(a, index, m)
        s = 0
        for j in range(index):
            s += pow(a, j, m)
        s *= c
        s %= m
        a_helper[2 + i][1] = s

        mins.append(lb)
        maxs.append(ub)

    if fail:
        exit(0)

    A = matrix(QQ, a_helper)
    minvector = vector(QQ, mins)
    maxvector = vector(QQ, maxs)

    AL = A.transpose().LLL().transpose()
    ALi = AL.inverse()

    LHSt = ALi * minvector
    RHSt = ALi * maxvector

    for i in range(dim):
        print(LHSt[i], RHSt[i], RHSt[i] - LHSt[i])

    search_area = 1
    ranges = []
    for x, y in zip(LHSt, RHSt):
        if floor(x) == floor(y) and y != floor(y):
            x -= 1
            y += 1
        if y > x:
            ranges.append(range(ceil(x), floor(y + 1)))
            search_area *= abs(ceil(y) - floor(x)) + 1
        else:
            ranges.append(range(floor(y), floor(x + 1)))
            search_area *= abs(floor(x) - floor(y)) + 1
    print(search_area)

    solutions = []
    print(ranges)

    for consider in product(*ranges):
        considervec = vector(ZZ, list(consider))
        check = AL * considervec

        # check if the current point is inside the box
        correct = True
        for k in range(dim):
            if minvector[k] > check[k] or maxvector[k] < check[k]:
                correct = False
        if correct:
            solutions.append(check)

    print(f"solution:{solutions}")

    seed = solutions[0][0] * 16

    for i in range(64):
        seed = inv_lcg(a, c, m, seed)
    old_seed = seed

    unsorted = unsort((a, c, m), old_seed, shuffled, 64)

    print(unsorted)

    r.sendlineafter(b"> ", b"3")

    r.sendlineafter(b"> ", binascii.hexlify(unsorted))

    r.interactive()

    exit(0)


if __name__ == "__main__":
    main()
```
