Logo
Overview
[Dicegang] Plane-or-Exchange

[Dicegang] Plane-or-Exchange

Mard Mard
March 12, 2026
10 min read
index

Plane-or-Exchange

Solver
Author
AdnanSlef
Category
Crypto
Points
108
Solves
304
Flag
dice{plane_or_planar_my_w0rds_4r3_411_knotted_up}

Alice and Bob had a brief exchange, and now they know something which I do not. Would you please help me to drop some Eves?

Introduction

I was quite busy so I rushed a bit on this post, I think I wrote this post just because i got motivated after “someone” posted Codex writeup. (hmmm i wonder who it is).

But yeah, This was a fun challenge so here’s the write up. Honestly, I don’t think this would get me 50 bucks but if i get it, i will happily use it to support the beer economy for Friendly Maltese Citizens. By buying a whole kattle.

TL;DR

I thought this protocol was just some broken Diffie Hellman but actually the public key is actually created by planar grid diagrams for knots. So A public key is formed by combining a public grid with a private grid and then scrambling them by grid diagram moves. The shared secret is derived from the normalized Alexander polynomial of another combined grid. The thing is this doesn’t really have anything that make this “solvable”, but how this challenge can be approached is by using normalized Alexander polynomial of the represented knot. which is also the quantity that is being hashed.

This polynomial shows these 2 information

  1. Invariant under the knot preserving grid moves used by the scramble step
  2. Multiplicative under connected sum

Because of these 2 facts, we can reconstruct the same shared polynomial from public data

Δshared=Δ(Apub)Δ(Bpub)Δ(P)\Delta_{\text{shared}} = \frac{\Delta(A_{\text{pub}})\Delta(B_{\text{pub}})}{\Delta(P)}

aaaaaand, this is quite enough to derive the same keystream as Alice and Bob and get the flag

Grid Diagrams and Knots

Warning: Math ahead

Well to get started we first have to understand the math it uses, and lots of reading The challenge works with the combinatorial presentation of knots using grid diagrams. To understand Grid Homology, I would like to sugess y’all to read “Grid Homology for Knots and Links” since these definitions are coming from that book, and it’s a really good book.

To understand the code, we will map the challenge’s data structures directly to the formal definitions provided in that book.

Definition 3.1.1 (Planar Grid Diagram)

A planar grid diagram G\mathbb{G} is an n×nn \times n grid on the plane. That is, a square with nn rows and nn columns of small squares. Furthermore, nn of these small squares are marked with an XX and nn of them are marked with an OO which is subject to these rules

  1. Each row has a single square marked with an XX and a single square marked with an OO
  2. Each column has a single square marked with an XX and a single square marked with an OO
  3. No square is marked with both an XX and an OO

The text after explains how this combinatorial object can be encoded as data

Note (Encoding Combinatorial object)

A grid diagram can be described by two permutations σO\sigma_{\mathbb{O}} and σX\sigma_{\mathbb{X}}. If there is an OO-marking in the intersection of the ithi^{\text{th}} column and the jthj^{\text{th}} row, then the permutation σO\sigma_{\mathbb{O}} maps ii to jj The permutation σX\sigma_{\mathbb{X}} is defined analogously, using the XX-markings in place of the OO-markings.

In the challenge script, a grid is represented exactly like this. In (x, o) the x and the o are arrays acting as the permutations σO\sigma_{\mathbb{O}} and σX\sigma_{\mathbb{X}}

So now how does this grid represent a knot??? Well time to do some more reading.

Note (Section 3.1.1)

A grid diagram G\mathbb{G} specifies an oriented link L\vec{L} via the following procedure. Draw oriented segments connecting the XX-marked squares to the OO-marked squares in each column, then draw oriented segments connecting the OO-marked squares to the XX-marked squares in each row, with the convention that the vertical segments always cross above the horizontal ones

And after this, the challenge checks that the diagram represents a single component knot, not a multi-component link and this condition is also, as you have guessed, it’s from the book lol.

Note (Remark 3.1.2)

The permutation σXσO1\sigma_{\mathbb{X}} \cdot \sigma_{\mathbb{O}}^{-1} can be decomposed as a product of \ell disjoint cycles for some \ell. This number is equal to the number of components of the link specified by the grid diagram.

The challenge’s validate(point) function makes us of this since it verifies the row/column uniqueness (Definition 3.1.1), and ensures the cycle decomposition of σXσO1\sigma_{\mathbb{X}} \cdot \sigma_{\mathbb{O}}^{-1}. Which results in exactly 11 cycle (Remark 3.1.2). This tells us that the grid represents a knot.

Scrambling & Cromwell’s Theorem

The protocal attempts to hide the private keys by “scrambling” the combined grids So what’s with the scrambling mathematically???

Theorem 3.1.9 (Cromwell's Theorem)

Two planar grid diagrams represent equivalent links if and only if there is a finite sequence of grid moves that transform one into the other.

If you want to explicitly define the moves the challenge uses in the scramble function to prove they are mathematically valid “grid moves” it uses these

For row/column swaps we use commutations.

Definition 3.1.6 (Commutations)

Consider a pair of consecutive columns in a grid diagram G\mathbb{G}. Suppose that the two intervals associated to the consecutive columns are either disjoint, or one is contained in the interior of the other. Interchanging these two columns gives rise to a new grid diagram G\mathbb{G}'. We say that the two grid diagrams G\mathbb{G} and G\mathbb{G}' differ by column commutation. A row commutation is defined analogously.

For shared square swaps we use switches.

Definition 3.1.10 (Switches)

If G\mathbb{G}' is obtained from G\mathbb{G} by interchanging a pair of special columns (where the XX-marking in one column is in the same row as the OO-marking in the other), then we say that G\mathbb{G} and G\mathbb{G}' are related by a switch. Similarly, if two consecutive rows have an XX- and an OO-marking in the same column, interchanging them is also called a switch. (Note: It also notes that grid diagrams that differ by a swith determine the same link type).

For wrapping rows/columns around the edges we use cyclic permutation

Lemma 3.2.4 (Cyclic Permutation)

A cyclic permutation is equivalent to a sequence of commutations in the plane, stabilizations, and destabilizations. (Because it is made entirely of valid grid moves, shifting the grid wraps doesn’t change the knot either).

If you think about it, all of these operations preserve the underlying knot type. Because the knot type is preserved, any topological invariant of the knot remains unchanged before and after scrambling. The scramble obscures the diagram but it doesn’t do anything to “obscure” the knot.

The Alexander Polynomial

To generate the shared secret, the challenge relies on calculate(G) and normalize(). What are these computing? Well time to bring out the book again, since it provides the exact formulas

First, the code builds a matrix based on winding numbers,

Definition 3.3.2 (Grid Matrix)

Form the n×nn \times n matrix whose (i,j)th(i,j)^{\text{th}} entry is obtained by raising the formal variable tt to the power given by (1)(-1) times the winding number of link diagram given by G\mathbb{G} around the (j1,ni)th(j -1, n-i)^{\text{th}} lattice point. Call this matrix the grid matrix, and denote it by M(G)\mathbf{M}(\mathbb{G})

Then, it adjusts the determinant of this matrix

Definition 3.3.4 (Grid Matrix)

Suppose that G\mathbb{G} is an n×nn \times n grid. Define the function DG(t)D_{\mathbb{G}}(t) to be the product

ϵ(G)det(M(G))(t1/2t1/2)1nta(G)\epsilon(\mathbb{G}) \cdot \det(\mathbf{M}(\mathbb{G})) \cdot (t^{-1/2} - t^{1/2})^{1-n} t^{a(\mathbb{G})}

This function now moves to a known topological invariant

Theorem 3.3.6

Let G\mathbb{G} be a grid diagram that represents L\vec{L}. Then, the function DGtD_{\mathbb{G}}{t} is a link invariant and it coincides with the symmetrized Alexander polynomial ΔL(t)\Delta_{\vec{L}}(t) of the link L\vec{L}.

In the challenge code calculate(G) build the grid matrix M(G)\mathbf{M}(\mathbb{G}) and evaluates its determinant, and normalize() multiplies it by the missing sign ϵ(G)\epsilon(\mathbb{G}) and Laurent monomial shift (t1/2t1/2)1nta(G)(t^{-1/2} - t^{1/2})^{1-n} \cdot t^{a(\mathbb{G})}

Thus, normalize(calculate(G)) is mathematically the same to evaluating the symmetrized Alexander polynomial ΔK(t)\Delta_K(t) of the knot!

Connected Sums

To mix the public and private grids, the protocol uses connect(g1, g2). From the code perspective, this takes two grids, places them diagonally adjacent, and links their ends to merge them into a single larger grid.

Topologically, this operation is exactly the connected sum, denoted K1#K2K_1 \# K_2. It splices two knots together to form a new knot. Why is this not a bueno idea for cryptography? Because you can actually abuse this identity.

Theorem 2.4.6

Suppose that the knot KS3K \subset S^3 has Alexander polynomial ΔK(t)\Delta_K(t). For any two knots K1K_1 and K2K_2

ΔK1#K2(t)=ΔK1(t)ΔK2(t)\Delta_{K_1 \# K_2}(t) = \Delta_{K_1}(t) \cdot \Delta_{K_2}(t)

We can see that the invariant used to derive the shared secret is perfectly multiplicative over the protocol’s combination function. Which makes it vulnerable.

Summarization of the Challenge Script

Now since we know basic background of Grid Homology, it’s time to summarize the challenge script into clean knot topology.

Let PP be the public base knot, AA be Alice’s private knot, and BB be Bob’s private knot. Let Δ(K)\Delta(K) denote the evaluation of the Alexander polynomial normalize(calculate(K)).

Alice’s public key ApubA_{\text{pub}} and Bob’s public key BpubB_{\text{pub}} are generated as

Apub=scramble(P#A),Bpub=scramble(P#B)A_{\text{pub}} = \text{scramble}(P \# A), \qquad B_{\text{pub}} = \text{scramble}(P \# B)

Because scrambling preserves the knot type (Theorem 3.1.9), and the Alexander polynomial is multiplicative (Theorem 2.4.6)

Δ(Apub)=Δ(P)Δ(A),Δ(Bpub)=Δ(P)Δ(B)\Delta(A_{\text{pub}}) = \Delta(P) \cdot \Delta(A), \qquad \Delta(B_{\text{pub}}) = \Delta(P) \cdot \Delta(B)

To compute the shared secret, Alice connects her private knot AA to Bob’s public knot BpubB_{\text{pub}} and extracts the polynomial

Δshared=Δ(A#Bpub)=Δ(A)Δ(Bpub)=Δ(A)Δ(P)Δ(B)\Delta_{\text{shared}} = \Delta(A \# B_{\text{pub}}) = \Delta(A) \cdot \Delta(B_{\text{pub}}) = \Delta(A) \cdot \Delta(P) \cdot \Delta(B)

Bob does the equivalent on his side, which arrives at the exact same polynomial.

Formulating Our Solver

We intercept the public base grid PP, and the two public keys ApubA_{\text{pub}} and BpubB_{\text{pub}}. Since we know that the Alexander polynomials operate over standard polynomial multiplication, We can compute the shared secret purely from public data

Δshared=Δ(Apub)Δ(Bpub)Δ(P)\Delta_{\text{shared}} = \frac{\Delta(A_{\text{pub}}) \cdot \Delta(B_{\text{pub}})}{\Delta(P)}

and by substituting the underlying values

(Δ(P)Δ(A))(Δ(P)Δ(B))Δ(P)=Δ(P)Δ(A)Δ(B)\frac{(\Delta(P)\Delta(A)) \cdot (\Delta(P)\Delta(B))}{\Delta(P)} = \Delta(P)\Delta(A)\Delta(B)

Dubbb we can now recover the polynomial Alice and Bob are hashing.

The Exploit Code

The script is quite simple if you understand the math. We can evaluate the polynomials using the challenge’s provided functions and do the polynomial division.

solve.py
import ast
import hashlib
import sympy as sp
 
t = sp.Symbol('t')
 
def sweep(ap):
    l = len(ap)
    current_row = [0] * l
    matrix = []
    for pair in ap:
        c1, c2 = sorted(pair)
        diff = pair[1] - pair[0]
        s = 1 if diff > 0 else -1 if diff < 0 else 0
        for c in range(c1, c2):
            current_row[c] += s
        matrix.append(list(current_row))
    return matrix
 
def mine(point):
    x, o = point
    return sweep(list(zip(x, o)))
 
def poly_stuff(exp_rows):
    n = len(exp_rows)
    row_shifts = [max(r) for r in exp_rows]
    M = [[sp.Poly(t**(row_shifts[i] - exp_rows[i][j]), t, domain='ZZ')
          for j in range(n)] for i in range(n)]
 
    prev = sp.Poly(1, t, domain='ZZ')
    sign = 1
 
    for k in range(n - 1):
        piv = k
        while piv < n and M[piv][k].is_zero:
            piv += 1
        if piv == n:
            return sp.Poly(0, t, domain='ZZ')
        if piv != k:
            M[k], M[piv] = M[piv], M[k]
            sign *= -1
 
        pivot = M[k][k]
        for i in range(k + 1, n):
            for j in range(k + 1, n):
                num = pivot * M[i][j] - M[i][k] * M[k][j]
                if prev != 1:
                    q, r = num.div(prev)
                    assert r.is_zero
                    M[i][j] = q
                else:
                    M[i][j] = num
            M[i][k] = sp.Poly(0, t, domain='ZZ')
        prev = pivot
 
    det = M[n - 1][n - 1]
    if sign == -1:
        det = -det
    return det
 
def invariant(point):
    n = len(point[0])
    det = poly_stuff(mine(point))
    fac = sp.Poly((1 - t)**(n - 1), t, domain='ZZ')
    q, r = det.div(fac)
    assert r.is_zero
    poly = sp.Poly(q.as_expr().expand(), t, domain='ZZ')
    min_deg = min(m[0] for m in poly.monoms())
    if min_deg:
        poly = sp.Poly(poly.as_expr() / t**min_deg, t, domain='ZZ')
    if poly.nth(0) < 0:
        poly = -poly
    return sp.expand(poly.as_expr())
 
with open("public.txt") as f:
    lines = f.read().splitlines()
 
A = tuple(ast.literal_eval(lines[0].split(": ", 1)[1]))
B = tuple(ast.literal_eval(lines[1].split(": ", 1)[1]))
P = tuple(ast.literal_eval(lines[2].split(": ", 1)[1]))
ct = bytes.fromhex(lines[3].split(": ", 1)[1])
 
dA = sp.Poly(invariant(A), t, domain='ZZ')
dB = sp.Poly(invariant(B), t, domain='ZZ')
dP = sp.Poly(invariant(P), t, domain='ZZ')
 
shared_poly, rem = (dA * dB).div(dP)
assert rem.is_zero
 
shared_secret = hashlib.sha256(str(sp.expand(shared_poly.as_expr())).encode()).hexdigest()
 
key = bytes.fromhex(shared_secret)
while len(key) < len(ct):
    key += hashlib.sha256(key).digest()
 
pt = bytes(c ^ k for c, k in zip(ct, key))
print(pt.decode())
user@Baekseju:~/CTF/Dicegang/planar$ python3 solve.py
dice{plane_or_planar_my_w0rds_4r3_411_knotted_up}