Introduction

    A few weeks ago I participated in BYUCTF 2026, a Jeopardy-style CTF organized by the BYU Cyberia club. I ended up solving a variety of challenges across different categories. As in previous competitions, I participated as the captain of Echelon Obscura. After two days of solving challenges across multiple categories, our team placed 10th out of 509 participating teams, making this one of our strongest performances so far.

    As readers of this blog probably know by now, I usually gravitate towards reverse engineering challenges. They are the ones I enjoy the most and, consequently, the ones I tend to document here. BYUCTF was no exception and most of the write ups that will follow in this series focus on reversing binaries.

    There was, however, one challenge that I could not ignore. It was a cryptography challenge called Hurtful. Even though cryptography is not the category I spend most of my time on, this challenge immediately stood out because of how elegantly it exploited a subtle weakness in textbook RSA. Solving it required much more than recognizing the vulnerability. It also required understanding why the attack works in the first place, which made it an excellent learning experience and an easy choice for the first article of this series.

Challenge Overview

    The challenge provided two files. The first one, enc.py, contains the script responsible for encrypting the flag.:

from Crypto.Util.number import bytes_to_long, getPrime
from flag import flag

ct = b"Congrats on making it all the way here. If you're looking at this challenge you obviously know a lot, just find the flag: " + flag
ct = bytes_to_long(ct)

e = 3
N = getPrime(1024) * getPrime(1024)

c = pow(ct, e, N)

print(f"N = {N}")
print(f"e = {e}")
print(f"c = {c}")

    The second file, output.txt, contains the public parameters produced by the script:

N = 19182059951550152194262985844167261329837098855450236222013760659170349018734494923926666667501340706425872438287170720903583823667756573458142648513352590833858660174432217965277179911355266513452278961332550556503321884404062276956158547580042536477663767813759729649129106469265655223451223809232904112545827951067852204320979668072724299447815067365854856364324801062529616146166750509449375848647583211376445559824835125899135120190217678172503092284624762383334136004808603157447096333303878501320079019667203448434374213128608508633749312972902710408469284343758812291029591531968790328542459486100584232853831
e = 3
c = 13131547353643964269974696530621442484862362408226564561566116636024604640465295092296980626925666439675971217402946439141553202254604849457188007454699538682918891457187624359386003061118876434043817877482088092956606930950252859797028902120711953259812881632304429935981366831846294320052038062427759377027934955686954417314137019396600765995589656261890382943632109387431016394449936269098790317502797885039391511502199770624977868648873042353283870936117111244787113481287058480173941942328575776017868390649935214113443493330011242794012152900909400012708779788657388488378264146239463590037604839491578237209775

    Unlike many reverse engineering challenges where the implementation itself has to be recovered, here we are given the entire encryption routine. This immediately shifts our attention away from understanding what the code does and towards understanding how it does it. If the challenge is solvable, the weakness is almost certainly hiding somewhere in the implementation rather than in the algorithm itself.

Analysis

    The first thing I usually do when looking at a cryptography challenge is read the code from top to bottom without thinking too much about possible attacks. The goal is simply to understand what information is available and whether anything looks unusual.

    The script starts by constructing the plaintext:

ct = b"Congrats on making it all the way here. If you're looking at this challenge you obviously know a lot, just find the flag: " + flag
ct = bytes_to_long(ct)

    This tells us something interesting straight away. The plaintext is not just the flag. Instead, the flag is appended to a long, fixed message before the whole string is converted into an integer. In cryptographic terms, this places us in a Known Plaintext Attack (KPA) scenario, where a significant portion of the plaintext is already known to the attacker. In this case, only the final bytes corresponding to the flag remain unknown. While a known plaintext alone is not enough to break RSA, it becomes particularly interesting when combined with other implementation weaknesses.

    Continuing through the script, another detail immediately caught my attention:

e = 3

    Most RSA implementations today use a public exponent of 65537, so seeing 3 naturally raises a few questions. At the same time, this alone is not enough to conclude that the implementation is vulnerable. A small public exponent is perfectly valid as long as RSA is used correctly.

    The last piece of information comes from the encryption itself.

c = pow(ct, e, N)

    The plaintext integer is encrypted directly without first applying a padding scheme such as OAEP (Optimal Asymmetric Encryption Padding) or PKCS#1 v1.5. In other words, the challenge uses textbook RSA, the simplest form of RSA in which the plaintext is exponentiated directly. Modern RSA implementations never encrypt raw messages directly because padding introduces randomness into the plaintext, preventing several classes of attacks that exploit deterministic encryption.

    Looking at these observations together, a much clearer picture begins to emerge. The implementation uses textbook RSA, the public exponent is set to 3, and almost the entire plaintext is known beforehand. Individually, none of these choices necessarily compromises RSA. Together, however, they create exactly the conditions under which several low public exponent attacks become applicable. At this point, I was fairly confident that the intended solution would not involve factoring the 2048-bit modulus, but rather exploiting the deterministic nature of textbook RSA to recover the unknown portion of the plaintext.

Exploiting the Vulnerability

    Having identified the characteristics of the implementation, the next step was to determine which attack could be applied. The first candidate was the classic Low Public Exponent Attack. Whenever RSA uses a small public exponent such as e = 3, the first thing worth checking is whether the plaintext can be recovered by simply computing the integer cube root of the ciphertext.

    Recall that textbook RSA encrypts a plaintext integer m according to:

c = m^e mod N

where:

  • m is the plaintext represented as an integer.
  • e is the public exponent.
  • N is the RSA modulus.

    For this challenge, the public exponent is equal to 3, therefore the encryption operation becomes:

c = m^3 mod N

    If the plaintext is sufficiently small such that:

m^3 < N

    then the modular reduction never takes place during encryption. In that case, the ciphertext is simply equal to the cube of the plaintext.

c = m^3

    Recovering the plaintext then becomes trivial, since it only requires computing the integer cube root of c.

    Unfortunately, this challenge does not satisfy that condition. The known prefix alone occupies 122 bytes, making the plaintext considerably larger than N^(1/3). Consequently, the value m^3 exceeds the modulus, modular reduction takes place as expected and the direct cube root attack is no longer applicable.

    Although that approach quickly reaches a dead end, it reveals something much more interesting. While the plaintext itself is too large, only a very small portion of it is actually unknown. The fixed prefix is completely known, leaving only the flag to recover. This effectively turns the problem into a Known Plaintext Attack (KPA), where the objective is not to recover the entire plaintext but only the unknown suffix.

    This is precisely the scenario addressed by Coppersmith’s Small Root Theorem! Instead of attacking RSA directly, Coppersmith showed that small unknown values can be recovered efficiently by treating them as roots of a polynomial modulo N.

    One of the most important results of the theorem states that the attack succeeds whenever the unknown value x satisfies the following bound:

|x| < N^(1/e)

    Since this challenge uses e = 3, the bound becomes:

|x| < N^(1/3)

    For a 2048-bit RSA modulus, N^(1/3) is approximately 2^682, which corresponds to roughly 85 bytes.

    The only unknown portion of the plaintext is the flag, whose length is approximately 35 bytes. Since 35 bytes is well below the theoretical bound of 85 bytes, the requirements for Coppersmith’s attack are comfortably satisfied.

    At this point, the intended solution became much clearer. Rather than attempting to recover the entire plaintext, the idea is to model only the unknown suffix as a variable, express the RSA equation as a polynomial and use Coppersmith’s method together with lattice reduction to recover the missing bytes efficiently.

Constructing the Polynomial

    Having established that the unknown portion of the plaintext satisfies Coppersmith’s bound, the next step is to express the encryption process as a polynomial whose unknown root corresponds to the flag.

    Since the prefix of the plaintext is completely known, we can separate the message into two components. Let p denote the known prefix after it has been converted into an integer and shifted to make room for the flag and let x represent the unknown flag interpreted as an integer.

    The complete plaintext can therefore be written as:

m = p + x

where:

  • p is the known portion of the plaintext.
  • x is the unknown flag that we want to recover.

    Because RSA encrypts the plaintext according to:

c = m^3 mod N

    we can substitute our expression for m:

(p + x)^3 ≡ c (mod N)

    This equation is exactly what we need. The unknown value x now appears as the root of a polynomial modulo N.

    Expanding the cube gives:

x^3 + 3px^2 + 3p^2x + (p^3 - c) ≡ 0 (mod N)

    or, equivalently,

f(x) = x^3 + 3px^2 + 3p^2x + (p^3 - c)

    Notice that the coefficient of the highest-degree term is equal to one, meaning that f(x) is a monic polynomial. This property is important because Coppersmith’s theorem assumes a monic polynomial when searching for small modular roots.

    At this stage, the problem has been transformed from recovering an RSA plaintext into finding a small integer x that satisfies:

f(x) ≡ 0 (mod N)

    Fortunately, this is precisely the type of problem Coppersmith’s method was designed to solve. The algorithm itself does not search for the root directly. Instead, it constructs a lattice from carefully chosen shifts of the polynomial and then applies the Lenstra–Lenstra–Lovász (LLL) lattice basis reduction algorithm. The reduced lattice reveals a new polynomial over the integers that shares the same small root as the original polynomial modulo N. Once this polynomial has been obtained, recovering the flag becomes a straightforward root-finding problem.

    The implementation used in this challenge follows Howgrave-Graham’s reformulation of Coppersmith’s method. Instead of relying on SageMath’s small_roots() function, I decided to implement the attack myself using the fpylll library. While this certainly required more work, it also made the attack much easier to understand. Building the lattice manually provided a much clearer picture of how the shifted polynomials are combined, how LLL reduction is applied and why the recovered polynomial still contains the small root we are looking for.

Implementing the Attack

    With the mathematical model established, implementing the attack becomes relatively straightforward. The exploit follows the same sequence of steps discussed in the previous section. It first performs the basic polynomial operations required by the lattice construction, generates the shifted polynomials described by Howgrave-Graham’s reformulation, applies LLL reduction, and finally verifies the recovered candidate roots against the original RSA equation.

    The script begins by importing the required libraries and defining the challenge parameters:

from Crypto.Util.number import bytes_to_long, long_to_bytes
from fpylll import IntegerMatrix, LLL

N = 19182059951550152194262985844167261329837098855450236222013760659170349018734494923926666667501340706425872438287170720903583823667756573458142648513352590833858660174432217965277179911355266513452278961332550556503321884404062276956158547580042536477663767813759729649129106469265655223451223809232904112545827951067852204320979668072724299447815067365854856364324801062529616146166750509449375848647583211376445559824835125899135120190217678172503092284624762383334136004808603157447096333303878501320079019667203448434374213128608508633749312972902710408469284343758812291029591531968790328542459486100584232853831
e = 3
c = 13131547353643964269974696530621442484862362408226564561566116636024604640465295092296980626925666439675971217402946439141553202254604849457188007454699538682918891457187624359386003061118876434043817877482088092956606930950252859797028902120711953259812881632304429935981366831846294320052038062427759377027934955686954417314137019396600765995589656261890382943632109387431016394449936269098790317502797885039391511502199770624977868648873042353283870936117111244787113481287058480173941942328575776017868390649935214113443493330011242794012152900909400012708779788657388488378264146239463590037604839491578237209775

KNOWN_PREFIX = b"Congrats on making it all the way here. If you're looking at this challenge you obviously know a lot, just find the flag: "

    Since the polynomial is represented as a list of coefficients, a few helper functions are required for polynomial multiplication and evaluation. These are later used both during the lattice construction and while applying Newton’s method to recover candidate roots from the reduced lattice:

def poly_mul(a, b):
    """Multiply two polynomials represented as coefficient lists."""
    result = [0] * (len(a) + len(b) - 1)
    for i, ai in enumerate(a):
        for j, bj in enumerate(b):
            result[i + j] += ai * bj
    return result


def poly_eval(coeffs, x):
    """Evaluate polynomial at x (Horner's method)."""
    val = 0
    for c in reversed(coeffs):
        val = val * x + c
    return val


def poly_deriv_eval(coeffs, x):
    """Evaluate derivative of polynomial at x."""
    val = 0
    for i in range(len(coeffs) - 1, 0, -1):
        val = val * x + i * coeffs[i]
    return val

    The core of the implementation is the coppersmith_small_roots() function. This is where the lattice described in the previous section is constructed. The function first computes the powers of the polynomial, generates the shifted polynomials:

g(i,j)(x) = N^(m-i) · x^j · f(x)^i

and finally appends the additional shifts required by Howgrave-Graham’s construction.

for i in range(m):
    for j in range(degree):
        scale = N ** (m - i)
        poly = [scale * coeff for coeff in f_powers[i]]
        poly = [0] * j + poly
        polys.append(poly)

    Once the shifted polynomials have been generated, they are converted into the lattice basis. Each column is scaled by the expected root bound X, exactly as described earlier, before the lattice is reduced using the LLL algorithm.

matrix = IntegerMatrix(dim, dim)

...

LLL.reduction(matrix)

    After the reduction step, every row of the lattice corresponds to a new polynomial over the integers. Instead of solving the original modular equation directly, the script evaluates these new polynomials and applies Newton’s method to recover candidate roots:

for start in [0, X // 8, X // 4, X // 2]:
    ...

    The attack itself is implemented separately. Since the challenge does not reveal the flag length, the script simply iterates over a reasonable range of candidate lengths. For each candidate, the known prefix is shifted left to make room for the unknown bytes, producing the value p introduced in the previous section:

p = bytes_to_long(known_prefix) << flag_bits

    Using this value, the polynomial:

f(x) = x³ + 3px² + 3p²x + (p³ - c)

is constructed exactly as derived earlier:

a0 = (pow(p, 3) - c) % N
a1 = (3 * pow(p, 2)) % N
a2 = (3 * p) % N

f = [a0, a1, a2, 1]

    The candidate roots returned by Coppersmith’s method are not immediately assumed to be correct. Instead, each one is substituted back into the original RSA equation and re-encrypted:

if pow(plaintext, e, N) == c:
    return long_to_bytes(plaintext)

    This final verification step ensures that the recovered value is indeed the original plaintext. Although lattice reduction may occasionally produce multiple candidate roots, only the correct one satisfies the original encryption equation and successfully reconstructs the flag.

Getting the Flag

    At this point, all the individual components of the exploit have been implemented and discussed. Bringing everything together results in the complete solver shown below. The script iterates over a range of plausible flag lengths, constructs the corresponding polynomial for each candidate, performs the lattice reduction using Coppersmith’s method and verifies every recovered root against the original RSA equation. Once a valid plaintext is found, the embedded flag is extracted automatically:

from Crypto.Util.number import bytes_to_long, long_to_bytes
from fpylll import IntegerMatrix, LLL


N = 19182059951550152194262985844167261329837098855450236222013760659170349018734494923926666667501340706425872438287170720903583823667756573458142648513352590833858660174432217965277179911355266513452278961332550556503321884404062276956158547580042536477663767813759729649129106469265655223451223809232904112545827951067852204320979668072724299447815067365854856364324801062529616146166750509449375848647583211376445559824835125899135120190217678172503092284624762383334136004808603157447096333303878501320079019667203448434374213128608508633749312972902710408469284343758812291029591531968790328542459486100584232853831
e = 3
c = 13131547353643964269974696530621442484862362408226564561566116636024604640465295092296980626925666439675971217402946439141553202254604849457188007454699538682918891457187624359386003061118876434043817877482088092956606930950252859797028902120711953259812881632304429935981366831846294320052038062427759377027934955686954417314137019396600765995589656261890382943632109387431016394449936269098790317502797885039391511502199770624977868648873042353283870936117111244787113481287058480173941942328575776017868390649935214113443493330011242794012152900909400012708779788657388488378264146239463590037604839491578237209775

KNOWN_PREFIX = b"Congrats on making it all the way here. If you're looking at this challenge you obviously know a lot, just find the flag: "

def poly_mul(a, b):
    """Multiply two polynomials represented as coefficient lists."""
    result = [0] * (len(a) + len(b) - 1)
    for i, ai in enumerate(a):
        for j, bj in enumerate(b):
            result[i + j] += ai * bj
    return result


def poly_eval(coeffs, x):
    """Evaluate polynomial at x (Horner's method)."""
    val = 0
    for c in reversed(coeffs):
        val = val * x + c
    return val


def poly_deriv_eval(coeffs, x):
    """Evaluate derivative of polynomial at x."""
    val = 0
    for i in range(len(coeffs) - 1, 0, -1):
        val = val * x + i * coeffs[i]
    return val

def coppersmith_small_roots(f_coeffs, X, N, m=4, t=3):
    """
    Find small integer roots of f(x) ≡ 0 (mod N) with |root| < X.

    Uses the Howgrave-Graham / Coppersmith lattice construction:
      - Shift polynomials: g_{i,j}(x) = N^{m-i} * x^j * f(x)^i
      - Scale columns by X^j so the Euclidean norm controls root size.
      - LLL reduction finds a short vector whose lift over Z has the same root.
      - Newton's method recovers the exact integer root.
    """
    d = len(f_coeffs) - 1          # polynomial degree (3 here)

    # Pre-compute powers f^0, f^1, ..., f^m as coefficient lists
    f_powers = [[1]]
    for _ in range(m):
        f_powers.append(poly_mul(f_powers[-1], f_coeffs))

    # Build shift polynomials
    polys = []
    for i in range(m):
        for j in range(d):
            # g_{i,j} = N^{m-i} * x^j * f^i
            scale = N ** (m - i)
            p = [scale * c for c in f_powers[i]]
            p = [0] * j + p          # multiply by x^j
            polys.append(p)
    for j in range(t):
        # Extra shifts: x^j * f^m  (no N factor)
        p = [0] * j + f_powers[m][:]
        polys.append(p)

    dim = len(polys)

    # Build the lattice matrix: row i is the scaled coefficient vector of polys[i].
    # Column j is multiplied by X^j so that short vectors correspond to small roots.
    M = IntegerMatrix(dim, dim)
    for i, poly in enumerate(polys):
        padded = (poly + [0] * dim)[:dim]   # truncate / zero-pad to dim columns
        for j in range(dim):
            M[i, j] = int(padded[j] * (X ** j))

    LLL.reduction(M)

    # Extract candidate roots from the reduced rows
    roots = []
    for row_idx in range(dim):
        v = [M[row_idx, j] for j in range(dim)]

        # Unscale: divide column j by X^j to recover polynomial coefficients
        try:
            coeffs_r = [v[j] // (X ** j) for j in range(dim)]
        except ZeroDivisionError:
            continue

        # Verify the division was exact (no rounding artefacts)
        if any(v[j] != coeffs_r[j] * (X ** j) for j in range(dim)):
            continue

        # Newton's method over the integers to find the root of this polynomial
        for x_start in [0, X // 2, X // 4, X // 8]:
            x = x_start
            for _ in range(400):
                fx  = poly_eval(coeffs_r, x)
                if fx == 0:
                    if 0 <= x < X:
                        roots.append(x)
                    break
                dfx = poly_deriv_eval(coeffs_r, x)
                if dfx == 0:
                    break
                x = x - fx // dfx

    return list(set(roots))

def attack(N, e, c, known_prefix, flag_len_range=range(10, 70)):
    """
    Iterate over plausible flag lengths and run Coppersmith for each.

    The plaintext is:  pt = known_prefix || flag
    Written as an integer:
        pt = prefix_shifted + x,  where  prefix_shifted = int(known_prefix) << (flag_bits)

    Polynomial to solve: f(x) = (prefix_shifted + x)^e - c ≡ 0 (mod N)
    Expanded for e=3:    f(x) = a0 + a1*x + a2*x^2 + x^3
    """
    for flag_len in flag_len_range:
        flag_bits    = flag_len * 8
        X            = 2 ** flag_bits
        p            = bytes_to_long(known_prefix) << flag_bits

        # Coppersmith only works when X < N^(1/e).
        # N is 2048 bits → N^(1/3) ≈ 2^682. Skip impossible sizes.
        if flag_bits >= N.bit_length() // e:
            print(f"[!] flag_len={flag_len}: X too large for Coppersmith, stopping.")
            break

        # Expand (p + x)^3 - c mod N
        a0 = (pow(p, 3) - c) % N
        a1 = (3 * pow(p, 2)) % N
        a2 = (3 * p) % N
        f  = [a0, a1, a2, 1]           # monic degree-3 polynomial

        print(f"[*] Trying flag_len={flag_len} ({flag_bits} bits) ...", end=" ", flush=True)

        candidates = coppersmith_small_roots(f, X, N, m=4, t=3)

        for x_cand in candidates:
            pt_cand = p + x_cand
            if pt_cand > 0 and pow(pt_cand, e, N) == c:
                print("FOUND!")
                plaintext = long_to_bytes(pt_cand)
                return plaintext

        print("no root.")

    return None

    
if __name__ == "__main__":
    print("=" * 60)
    print("RSA Known-Prefix Coppersmith Solver")
    print("=" * 60)
    print(f"N = {N.bit_length()} bits")
    print(f"e = {e}")
    print(f"Known prefix length = {len(KNOWN_PREFIX)} bytes")
    print(f"Coppersmith bound = N^(1/e) ≈ 2^{N.bit_length() // e} bits ≈ {N.bit_length() // e // 8} bytes")
    print()

    result = attack(N, e, c, KNOWN_PREFIX)

    if result:
        print()
        print("[+] Plaintext recovered:")
        print(result.decode(errors="replace"))
        flag_start = result.find(b"byuctf{")
        if flag_start != -1:
            flag_end = result.find(b"}", flag_start) + 1
            print(f"\n[+] Flag: {result[flag_start:flag_end].decode()}")
    else:
        print("\n[-] Attack failed. Try increasing m or the flag_len range.")

    Executing the script produces the following output:

python solver.py
============================================================
RSA Known-Prefix Coppersmith Solver
============================================================
N = 2048 bits
e = 3
Known prefix length = 122 bytes
Coppersmith bound = N^(1/e) ≈ 2^682 bits ≈ 85 bytes

[*] Trying flag_len=10 (80 bits) ... no root.
[*] Trying flag_len=11 (88 bits) ... no root.
[*] Trying flag_len=12 (96 bits) ... no root.
[*] Trying flag_len=13 (104 bits) ... no root.
[*] Trying flag_len=14 (112 bits) ... no root.
[*] Trying flag_len=15 (120 bits) ... no root.
[*] Trying flag_len=16 (128 bits) ... no root.
[*] Trying flag_len=17 (136 bits) ... no root.
[*] Trying flag_len=18 (144 bits) ... no root.
[*] Trying flag_len=19 (152 bits) ... no root.
[*] Trying flag_len=20 (160 bits) ... no root.
[*] Trying flag_len=21 (168 bits) ... no root.
[*] Trying flag_len=22 (176 bits) ... no root.
[*] Trying flag_len=23 (184 bits) ... no root.
[*] Trying flag_len=24 (192 bits) ... no root.
[*] Trying flag_len=25 (200 bits) ... no root.
[*] Trying flag_len=26 (208 bits) ... no root.
[*] Trying flag_len=27 (216 bits) ... no root.
[*] Trying flag_len=28 (224 bits) ... no root.
[*] Trying flag_len=29 (232 bits) ... no root.
[*] Trying flag_len=30 (240 bits) ... no root.
[*] Trying flag_len=31 (248 bits) ... no root.
[*] Trying flag_len=32 (256 bits) ... no root.
[*] Trying flag_len=33 (264 bits) ... no root.
[*] Trying flag_len=34 (272 bits) ... no root.
[*] Trying flag_len=35 (280 bits) ... FOUND!

[+] Plaintext recovered:
Congrats on making it all the way here. If you're looking at this challenge you obviously know a lot, just find the flag: byuctf{cuz_st3r30typ3s_hurt_92de04}

[+] Flag: byuctf{cuz_st3r30typ3s_hurt_92de04}

    As expected, the attack succeeds once the correct flag length is reached. The recovered plaintext contains the original message together with the embedded flag, confirming that the unknown suffix was successfully reconstructed without ever factoring the 2048-bit RSA modulus. Instead, the attack exploits the combination of a known plaintext prefix, a small public exponent and the absence of padding to recover the missing bytes directly.

Conclusion

    At first glance, Hurtful appears to be a straightforward RSA challenge. The modulus is 2048 bits long, making any attempt to factor it computationally infeasible, and the encryption routine itself consists of only a few lines of code. However, as is often the case with cryptographic systems, the weakness does not lie in the algorithm itself but in the way it is implemented.

    By combining three seemingly harmless design choices—a small public exponent, the use of textbook RSA, and a plaintext with a long known prefix—the challenge becomes vulnerable to Coppersmith’s Small Root Attack. Rather than attacking the RSA modulus directly, the solution exploits the deterministic nature of textbook RSA and the fact that only a small portion of the plaintext remains unknown. It is a great example of why secure cryptographic implementations depend not only on strong algorithms but also on using them correctly.

    Although I primarily focus on reverse engineering challenges, I found this one particularly enjoyable because it offered the opportunity to explore an attack that I had previously encountered only from a theoretical perspective. Implementing the lattice construction manually using Howgrave-Graham’s reformulation and LLL reduction, instead of relying on SageMath’s built-in functionality, also made the underlying ideas much clearer and provided a much deeper understanding of how Coppersmith’s method works.

    Overall, I think Hurtful was an excellent challenge. It demonstrates that modern cryptography is rarely broken by defeating the mathematics behind an algorithm. More often than not, vulnerabilities arise from subtle implementation choices that, when combined, invalidate the assumptions on which the security of the scheme relies.

    This concludes the first write-up from BYUCTF 2026. The next articles in this series will return to my usual focus on reverse engineering, where I’ll be covering several interesting binaries that required a combination of static analysis, dynamic analysis and a fair amount of experimentation to solve. As always, I hope you found this write-up useful and if you spot anything that could be improved or have a different approach to solving the challenge, feel free to reach out!