VKACTF 2025 — Гусиные Кривые
Overview
We’re given the following C++ code. Since the main language of VKACTF is Russian, there are many Russian texts among the code:
#include <iostream>
#include <fstream>
#include <set>
#include <string>
#include <cryptopp/osrng.h>
#include <cryptopp/integer.h>
#include <cryptopp/nbtheory.h>
using namespace CryptoPP;
using namespace std;
Integer NextPrime(const Integer& n) {
Integer p = n;
if (p % 2 == 0) p++;
while (!IsPrime(p)) {
p += 2;
}
return p;
}
Integer Tonnelli_Shanks(const Integer& a, const Integer& p) {
if (Jacobi(a, p) != 1) return 0;
if (a == 0) return 0;
if (p == 2) return 0;
if (p % 4 == 3) {
return a_exp_b_mod_c(a, (p + 1) / 4, p);
}
Integer s = p - 1;
int e = 0;
while (s % 2 == 0) {
s /= 2;
e++;
}
Integer n = 2;
while (Jacobi(n, p) != -1) {
n++;
}
Integer x = a_exp_b_mod_c(a, (s + 1) / 2, p);
Integer b = a_exp_b_mod_c(a, s, p);
Integer g = a_exp_b_mod_c(n, s, p);
int r = e;
while (true) {
Integer t = b;
int m = 0;
for (; m < r; m++) {
if (t == 1) break;
t = a_exp_b_mod_c(t, 2, p);
}
if (m == 0) return x;
Integer gs = a_exp_b_mod_c(g, Integer::Power2(r - m - 1), p);
g = (gs * gs) % p;
x = (x * gs) % p;
b = (b * g) % p;
r = m;
}
}
class EllipticCurve {
public:
Integer p, a, b;
EllipticCurve(const Integer& p, const Integer& a, const Integer& b)
: p(p), a(a), b(b) {
if (!check_curve()) {
throw runtime_error("Заяц – Гусь! Кривая - Некривая!");
}
}
bool check_curve() const {
Integer d = -16 * (4*a*a*a + 27*b*b);
return (d % p) != 0;
}
Integer lift_x(const Integer& px) const {
Integer y2 = (a_exp_b_mod_c(px, 3, p) + a*px + b) % p;
Integer py = Tonnelli_Shanks(y2, p);
if (py == 0) {
throw runtime_error("Точка не пренадлежит кривой!");
}
return py;
}
};
int main() {
AutoSeededRandomPool prng;
ifstream flag_file("flag.txt", ios::binary);
if (!flag_file) {
cerr << "Флю… флю… флюгегехайм… Стоп-слово! Стоп-слово!" << endl;
return 1;
}
string flag_str((istreambuf_iterator<char>(flag_file)), istreambuf_iterator<char>());
Integer flag(reinterpret_cast<const byte*>(flag_str.data()), flag_str.size());
cout <<""<< endl;
cout << "Я гусь! Я до тебя (додолблюсь!)" << endl;
cout <<""<< endl;
Integer p, a, b, fy;
while (true) {
p = Integer(prng, 762); // Семьсот шестьдесят два... Семь сотен. Шесть десятков. И два.
p = NextPrime(p);
a = Integer(prng, 512);
b = Integer(prng, 512);
try {
EllipticCurve E(p, a, b);
fy = E.lift_x(flag);
cout << "p = " << p << endl;
cout << "Истинный y = " << fy << endl;
break;
} catch (...) {
continue;
}
}
set<Integer> checked;
int count = 0;
while (count < 3826) {
Integer x(prng, 2, p-1);
if (checked.count(x) || x < Integer::Power2(512) ||
(x > p ? x - p : p - x) < Integer::Power2(512)) {
cout << "Ломай меня полностью!" << endl;
continue;
}
try {
Integer e(prng, 55);
cout << "e = " << e << endl;
EllipticCurve E(p, a^e, b^e);
Integer py = E.lift_x(x);
checked.insert(x);
cout << "x = " << x << endl;
cout << "y = " << py << endl;
count++;
} catch (...) {
cout << "Жгучие пироги! Кто ж думал-то, что вот так подряд техника будет сбоить?" << endl;
}
cout << "Продолжаем > ";
string more;
getline(cin, more);
if (more == "Stop") {
break;
}
}
cout << "Я гусь! Я, пожалуй, (убегусь!)" << endl;
return 0;
}
Despite the code is tricky, the math description of the challenge is simple. We could reformulate it as following.
Suppose we have a finite field $\mathbb{F}$ and an elliptic curve $\mathrm{E’}(\mathbb{F}, [a, b])$. Then we select a point $\mathrm{P}(x’, y’)$ where $x’$ is a flag. We know only $\mathbb{F}$ and $y’$, parameters $a$ and $b$ remain unknown.
Then we could repeatedly do the following operation:
- generate a random 55-bit integer $e$
- create another elliptic curve $\mathrm{E}(\mathbb{F}, [a\text{^}e, b\text{^}e])$
- take a point $\mathrm{Q}(x, y)$ on the curve $\mathrm{E}$
- print integer $e$ and point $\mathrm{Q}$
We need to recover original parameters $a$ and $b$ from the given pairs $(e_i, \mathrm{Q}_i)$.
Description
From the definition of the elliptic curve we know that the point $\mathrm{Q}(x, y)$ lies on the curve $\mathrm{E}(\mathbb{F}, [a, b])$ when the following equation holds in $\mathbb{F}$:
$$y^2 \equiv x^3 + a \cdot x + b$$
In our case we’re given an access to $n$ pairs of $(e_i, \mathbb{Q}_i)$, so we could construct the system of equations in $\mathbb{F}$:
$$\left\{ \begin{aligned} y_1^2 & \equiv x_1^3 + a \text{^} e_1 \cdot x_1 + b \text{^} e_1 \\ y_2^2 & \equiv x_2^3 + a \text{^} e_2 \cdot x_2 + b \text{^} e_2 \\ & \vdots \\ y_n^2 & \equiv x_n^3 + a \text{^} e_n \cdot x_n + b \text{^} e_n \\ \end{aligned} \right.$$
The only unknown variables are $a$ and $b$, so it’s an overdetermined system of equations. But since numbers $e_i$ are relative big, it should be hard to solve. Shouldn’t it?
Linearization
The significant trick is a ^
operator. This is not exponentiation as it might seem at first glance, the ^
operator in C++ is XOR. Therefore the created curve $\mathrm{E}$ has the following definition:
$$\mathrm{E}(\mathbb{F}, [a \oplus e, b \oplus e])$$
And our system of equations transforms to:
$$\left\{\begin{aligned} y_1^2 & \equiv x_1^3 + (a \oplus e_1) \cdot x_1 + (b \oplus e_1) \\ y_2^2 & \equiv x_2^3 + (a \oplus e_2) \cdot x_2 + (b \oplus e_2) \\ & \vdots \\ y_n^2 & \equiv x_n^3 + (a \oplus e_n) \cdot x_n + (b \oplus e_n) \\ \end{aligned}\right.$$
We could notice that the system is actually linear. Let’s do the following:
- set $a^{(H)}$ to $(512-55)$ most significant bits of $a$
- set $b^{(H)}$ to $(512-55)$ most significant bits of $b$
- set $(a_0^{(L)}, a_1^{(L)}, …, a_{54}^{(L)})$ to 55 least significant bits of $a$
- set $(b_0^{(L)}, b_1^{(L)}, …, b_{54}^{(L)})$ to 55 least significant bits of $b$
- and set $(e_{i,0}, e_{i,1}, …, e_{i,54})$ to bits of $e$
- then $a \oplus e_i = 2^{55} \cdot a^{(H)} + 2^{54} \cdot e_{i,54} \cdot a_{54}^{(L)} + 2^{53} \cdot e_{i,53} \cdot a_{53}^{(L)} + … + 2^0 \cdot e_{i,0} \cdot a_0^{(L)}$
- then $b \oplus e_i = 2^{55} \cdot b^{(H)} + 2^{54} \cdot e_{i,54} \cdot b_{54}^{(L)} + 2^{53} \cdot e_{i,53} \cdot b_{53}^{(L)} + … + 2^0 \cdot e_{i,0} \cdot b_0^{(L)}$
Now we can rewrite the system as following:
$$\left\{\begin{aligned} y_1^2 - x_1^3 & \equiv \left( 2^{55} \cdot a^{(H)} + \sum_{j=0}^{54} 2^j \cdot e_{1,j} \cdot a_j^{(L)} \right) \cdot x_1 + \left( 2^{55} \cdot b^{(H)} + \sum_{j=0}^{54} 2^j \cdot e_{1,j} \cdot b_j^{(L)} \right) \\ y_2^2 - x_2^3 & \equiv \left( 2^{55} \cdot a^{(H)} + \sum_{j=0}^{54} 2^j \cdot e_{2,j} \cdot a_j^{(L)} \right) \cdot x_2 + \left( 2^{55} \cdot b^{(H)} + \sum_{j=0}^{54} 2^j \cdot e_{2,j} \cdot b_j^{(L)} \right) \\ & \vdots \\ y_n^2 - x_n^3 & \equiv \left( 2^{55} \cdot a^{(H)} + \sum_{j=0}^{54} 2^j \cdot e_{n,j} \cdot a_j^{(L)} \right) \cdot x_n + \left( 2^{55} \cdot b^{(H)} + \sum_{j=0}^{54} 2^j \cdot e_{n,j} \cdot b_j^{(L)} \right) \\ \end{aligned}\right.$$
It’s obvious that the system became linear. Note that we know everything except $a^{(H)}, b^{(H)}, a_j^{(L)}, b_j^{(L)}$. So the system has $55 + 55 + 2 = 112$ unknowns. It’s still solvable because we have access to $3826$ pairs of $(e_i, \mathrm{Q}_i)$. Therefore, we just need to solve matrix equation.
Suppose $T$ is a vector of left-hand sides:
$$T = \begin{pmatrix} y_1^2 - x_1^3 \\ y_2^2 - x_2^3 \\ \vdots \\ y_n^2 - x_n^3 \\ \end{pmatrix}$$
And $M$ is a matrix defined as follows:
$$M = \begin{pmatrix} 2^{55} \cdot x_1 & 2^{54} \cdot e_{1,54} \cdot x_1 & … & 2^0 \cdot e_{1,0} \cdot x_1 & 2^{55} & 2^{54} \cdot e_{1,54} & 2^{53} \cdot e_{1,53} & … & 2^0 \cdot e_{1,0} \\ 2^{55} \cdot x_2 & 2^{54} \cdot e_{2,54} \cdot x_2 & … & 2^0 \cdot e_{2,0} \cdot x_2 & 2^{55} & 2^{54} \cdot e_{2,54} & 2^{53} \cdot e_{2,53} & … & 2^0 \cdot e_{2,0} \\ \vdots & \vdots & \ddots & \vdots & \vdots & \vdots & \vdots & \ddots & \vdots \\ 2^{55} \cdot x_n & 2^{54} \cdot e_{n,54} \cdot x_n & … & 2^0 \cdot e_{n,0} \cdot x_n & 2^{55} & 2^{54} \cdot e_{n,54} & 2^{53} \cdot e_{n,53} & … & 2^0 \cdot e_{n,0} \\ \end{pmatrix} $$
And $X$ is a vector of unknowns:
$$X = \begin{pmatrix} a^{(H)} \\ a_{54}^{(L)} \\ \vdots \\ a_0^{(L)} \\ b^{(H)} \\ b_{54}^{(L)} \\ \vdots \\ b_{0}^{(L)} \\ \end{pmatrix}$$
Then we have to solve the following matrix equation in $\mathbb{F}$:
$$MX = T$$
Implementation
Let’s use sagemath.
We will skip the client implementation, suppose we already have the following values:
p
is an order of finite field $\mathbb{F}$y_flag
is a $y’$-coordinate of a flag point $\mathrm{P}(x’, y’)$es
andQs
are the given arrays of $e_i$ and $\mathrm{Q}_i$ respectively
Here is a function to recover the parameters $a$ and $b$:
def recover_parameters(p, es, Qs):
F = GF(p)
n = len(es)
T = vector(F, [(y^2 - x^3) for x, y in Qs])
M = Matrix(F, n, 112)
for i in range(n):
e = es[i]
x, y = Qs[i]
e_bits = [(e >> i) & 1 for i in range(55)]
M[i, 0] = 2^55 * x
for j in range(55):
M[i, 1 + j] = 2^j * e_bits[j] * x
M[i, 1 + 55] = 2^55
for j in range(55):
M[i, 1 + 55 + 1 + j] = 2^j * e_bits[j]
X = M.solve_right(T)
a_H = X[0]
b_H = X[1 + 55]
a_L, b_L = 0, 0
for j in range(55):
a_bit = X[1 + j]
b_bit = X[1 + 55 + 1 + j]
a_L += (int(a_bit) & 1) * 2^j
b_L += (int(b_bit) & 1) * 2^j
a = 2^55 * a_H + (int(a_L) & (2^55))
b = 2^55 * b_H + (int(b_L) & (2^55))
return a, b
And then we just recover $x’$ coordinate of $\mathrm{P}$:
def reconstruct_flag(p, a, b, y_flag):
F = GF(p)
R.<X> = PolynomialRing(F)
poly = (y_flag^2) - (X^3 + a*X + b)
for x_flag, _ in poly.roots():
flag = int(x_flag).to_bytes(762 // 8, 'big')
return flag
Flag
vka{goose_swearer_vs_cryptor}
Conclusion
The indended solution involves building a lattice and solving CVP, so the solution above is unintended. But anyway I’ve enjoyed the challenge, many thanks to the organizing team Red Cadets.