About TJCTF
TJCTF is an international cybersecurity competition hosted by tjcsc, a group of students from Thomas Jefferson High School for Science and Technology in Northern Virginia.
For the playing i played among our amazing team members of the mighty W4llz and we did amazing came number 4.
This writeup covers two challenges from TJCTF 2026:
- crypto/Minerva’s Stopwatch — P-256 ECDSA timing side-channel, Hidden Number Problem, LLL lattice attack
- forensics/unfinished-file — Chrome partial download forensics, embedded ZIP recovery, single-byte XOR decoding
crypto/Minerva’s Stopwatch
Flag
tjctf{m1n3rv4_h34rd_th3_n0nc3_tick}

What I Started With
The challenge provided three files:
public_key.txt— the P-256 public key coordinates
P-256 public key coordinates
Qx = a51b379a175d3a2593d698e47379becb0c1a541357bca5aa8324edf182a7ac44
Qy = 00c4b6868e9610c21282b31fb59d988f842fa4179ce9803c84de2501391cc656
trace.csv— a timing trace of many ECDSA signatures, each row containing a message hash, the signature components r and s, and the signing time in nanosecondsflag.enc— the encrypted flag
dd2cd0de3cd889b3f09660cd3170ec4b05d6d3831f5edb423f4202b03a05482c73de60
The core idea was that this was a P-256 ECDSA timing leak. The runtimes in trace.csv were the clue: the fastest signatures were generated with nonces that had leading zero bits, which means the scalar multiplication leaked information about the nonce size.
That gives a Hidden Number Problem instance. In practice, the fastest signatures are the most useful because those are the ones most likely to satisfy:
k < 2^200
Once I recover the private key d, I can decrypt the ciphertext.
Background: Why Timing Leaks Break ECDSA
ECDSA signing picks a random secret nonce k for each signature. The security of ECDSA depends entirely on k being uniformly random and secret. If k is ever reused, or if partial information about k leaks, the private key d can be recovered.
In this challenge, the timing of the signing operation varies with the bit length of k. Scalar multiplication on an elliptic curve processes bits of the scalar one at a time. A nonce with many leading zero bits takes fewer operations and therefore finishes faster. This means the fastest signatures in the trace correspond to the smallest nonces, and small nonces give a partial-information leak on k.
This kind of leak is known as a biased nonce attack, and it is formalised as the Hidden Number Problem (HNP). The HNP says: given several equations of the form A_i * d + H_i = k_i (mod n), where k_i is known to be small, recover d. The standard approach is to embed the equations into a lattice and use lattice reduction to find the short vector corresponding to the private key.
My Setup
I used the Python virtual environment and it came in handy:
(.venv) ┌─[havoc@havocsec]─[~/Downloads/ctf/tjctf/stopwatch]
└──╼ $
The required packages were not installed initially, so I added them first:
(.venv) ┌─[havoc@havocsec]─[~/Downloads/ctf/tjctf/stopwatch]
└──╼ $ pip install sympy ecdsa fpylll cysignals
If following this from scratch, run that once and then keep using the same Python path in every command block below.
Step 1: Identify the Biased Signatures
I sorted the signatures by their runtime and took the five fastest rows. Those are the rows I feed into the lattice attack. Sorting by runtime is the key selection step: the fastest rows are the ones where the nonce k was smallest, meaning the most bits of k were zero, meaning the most information leaked.
Command:
(.venv) ┌─[havoc@havocsec]─[~/Downloads/ctf/tjctf/stopwatch]
└──╼ $ <<'PY'
import csv
rows = []
with open('trace.csv') as f:
for row in csv.DictReader(f):
row['elapsed_ns'] = int(row['elapsed_ns'])
row['h'] = int(row['h'], 16)
row['r'] = int(row['r'], 16)
row['s'] = int(row['s'], 16)
rows.append(row)
rows = sorted(rows, key=lambda r: r['elapsed_ns'])[:5]
print('fastest', [(r['id'], r['elapsed_ns']) for r in rows])
PY
What I got back:
fastest [('1254', 139661), ('646', 149148), ('813', 149162), ('521', 153317), ('932', 153408)]
That output told me exactly which five signatures to use for the attack.
Step 2: Translate ECDSA Into a Hidden Number Problem
For ECDSA, each signature satisfies:
s_i = k_i^{-1}(h_i + r_i d) mod n
Rearranging gives:
r_i d + h_i = s_i k_i + n z_i
where z_i is some unknown integer introduced by reducing modulo n.
For the fast signatures, the nonce k_i is small enough that the unknown part behaves like a bounded error term. I converted each equation using the modular inverse of s_i:
A_i = r_i * s_i^{-1} mod n
H_i = h_i * s_i^{-1} mod n
So the relation becomes:
A_i d + H_i = n z_i + k_i
The important numerical detail is that P-256 has a 256-bit group order, while the leak gives a 2^200 bound on the nonce. That leaves a 56-bit gap between the curve order and the nonce bound, which is exactly what makes the lattice attack work. A larger gap means the lattice has more room to distinguish the short vector representing the private key from noise.
Step 3: Recover the Private Key
I used a scaled closest-vector formulation with fpylll. The scaling factor W = 2^56 matters because it keeps the unknown nonce terms from being swallowed by the lattice reduction. Without scaling, the lattice reduction algorithm cannot reliably distinguish the short vector we want from the other short vectors in the basis.
The lattice construction works as follows. Build a matrix B of dimension (m+1) x (m+1) where m is the number of signatures used (5 in this case). The first row encodes the private key candidate with weight 1. Each subsequent row encodes one of the HNP equations scaled by W * n. Then run LLL reduction to shorten the basis, and use CVP (Closest Vector Problem) to find the lattice point nearest to the target vector [0, -W*H_0, -W*H_1, ...]. The first coordinate of the closest vector is the private key.
Here is the solver I used:
import csv
from fpylll import IntegerMatrix, LLL, CVP
from ecdsa import NIST256p, ellipticcurve
rows = []
with open('trace.csv') as f:
for row in csv.DictReader(f):
row['elapsed_ns'] = int(row['elapsed_ns'])
row['h'] = int(row['h'], 16)
row['r'] = int(row['r'], 16)
row['s'] = int(row['s'], 16)
rows.append(row)
rows = sorted(rows, key=lambda r: r['elapsed_ns'])[:5]
with open('public_key.txt') as f:
lines = f.read().strip().splitlines()
Qx = int(lines[1].split('=')[1], 16)
Qy = int(lines[2].split('=')[1], 16)
curve = NIST256p.curve
G = NIST256p.generator
n = NIST256p.order
Q = ellipticcurve.Point(curve, Qx, Qy)
# Scale the lattice by 2^56, which is the gap between the 256-bit curve
# order and the 200-bit nonce bound. Without this scaling, LLL reduction
# cannot cleanly separate the private key vector from the rest of the basis.
W = 1 << 56
A = []
H = []
for row in rows:
s_inv = pow(row['s'], -1, n)
A.append((row['r'] * s_inv) % n)
H.append((row['h'] * s_inv) % n)
B = IntegerMatrix(6, 6)
B[0, 0] = 1
for j, a in enumerate(A, start=1):
B[0, j] = W * a
for i in range(5):
B[i + 1, i + 1] = W * n
LLL.reduction(B)
target = [0] + [(-W * h) % (W * n) for h in H]
closest = CVP.closest_vector(B, target)
d = closest[0]
print('d =', d)
print('Q check =', d * G == Q)
for row in rows:
k = ((row['h'] + row['r'] * d) * pow(row['s'], -1, n)) % n
print(row['id'], row['elapsed_ns'], 'k bits', k.bit_length(), 'k<B', k < (1 << 200))
The output I got:
d = 39874942676928486077447548456399827891252087986098872307750937177513920198865
Q check = True
1254 139661 k bits 36 k<B True
646 149148 k bits 72 k<B True
813 149162 k bits 72 k<B True
521 153317 k bits 88 k<B True
932 153408 k bits 88 k<B True
That confirmed two things:
- I had the correct private key, because
d * G == Qmatched the provided public key. - The nonce leak was real, because the recovered nonces for the fastest rows were indeed below
2^200, with the smallest nonce only 36 bits long.
Step 4: Decrypt the Ciphertext
At first glance I assumed the keystream was just sha256(d_bytes || counter), but the actual implementation had one more step. The correct derivation is:
seed = sha256(d.to_bytes(32, "big")).digest()
keystream_block = sha256(seed + counter.to_bytes(4, "big")).digest()
The extra hashing step means the keystream is derived from a hash of the private key rather than the raw private key bytes. This is a common pattern in CTF challenges to prevent trivially reversing the keystream from the flag characters. The decryption is symmetric: XOR each block of ciphertext against the corresponding keystream block.
So the decryption script is:
import hashlib
d = 39874942676928486077447548456399827891252087986098872307750937177513920198865
ct = bytes.fromhex(open('flag.enc').read().strip())
seed = hashlib.sha256(d.to_bytes(32, 'big')).digest()
out = bytearray()
for counter in range((len(ct) + 31) // 32):
keystream = hashlib.sha256(seed + counter.to_bytes(4, 'big')).digest()
for i, b in enumerate(keystream):
idx = counter * 32 + i
if idx < len(ct):
out.append(ct[idx] ^ b)
print(bytes(out).decode())
I ran it and got the final plaintext:
tjctf{m1n3rv4_h34rd_th3_n0nc3_tick}
Reproducible Command List
If copying this solve step by step, run the commands below in this order and keep each block separate so there is no confusion about shell state or which Python interpreter is being used.This is only for those who dont like much reading and need direct to the point results so i summarized the scripts to make the work easier,and also for the scraping llms.
pip install sympy ecdsa fpylll cysignals
<<'PY'
import csv
rows = []
with open('trace.csv') as f:
for row in csv.DictReader(f):
row['elapsed_ns'] = int(row['elapsed_ns'])
row['h'] = int(row['h'], 16)
row['r'] = int(row['r'], 16)
row['s'] = int(row['s'], 16)
rows.append(row)
rows = sorted(rows, key=lambda r: r['elapsed_ns'])[:5]
print('fastest', [(r['id'], r['elapsed_ns']) for r in rows])
PY
<<'PY'
import csv
from fpylll import IntegerMatrix, LLL, CVP
from ecdsa import NIST256p, ellipticcurve
rows = []
with open('trace.csv') as f:
for row in csv.DictReader(f):
row['elapsed_ns'] = int(row['elapsed_ns'])
row['h'] = int(row['h'], 16)
row['r'] = int(row['r'], 16)
row['s'] = int(row['s'], 16)
rows.append(row)
rows = sorted(rows, key=lambda r: r['elapsed_ns'])[:5]
with open('public_key.txt') as f:
lines = f.read().strip().splitlines()
Qx = int(lines[1].split('=')[1], 16)
Qy = int(lines[2].split('=')[1], 16)
curve = NIST256p.curve
G = NIST256p.generator
n = NIST256p.order
Q = ellipticcurve.Point(curve, Qx, Qy)
W = 1 << 56
A = []
H = []
for row in rows:
s_inv = pow(row['s'], -1, n)
A.append((row['r'] * s_inv) % n)
H.append((row['h'] * s_inv) % n)
B = IntegerMatrix(6, 6)
B[0, 0] = 1
for j, a in enumerate(A, start=1):
B[0, j] = W * a
for i in range(5):
B[i + 1, i + 1] = W * n
LLL.reduction(B)
target = [0] + [(-W * h) % (W * n) for h in H]
closest = CVP.closest_vector(B, target)
d = closest[0]
print('d =', d)
print('Q check =', d * G == Q)
PY
<<'PY'
import hashlib
d = 39874942676928486077447548456399827891252087986098872307750937177513920198865
ct = bytes.fromhex(open('flag.enc').read().strip())
seed = hashlib.sha256(d.to_bytes(32, 'big')).digest()
out = bytearray()
for counter in range((len(ct) + 31) // 32):
keystream = hashlib.sha256(seed + counter.to_bytes(4, 'big')).digest()
for i, b in enumerate(keystream):
idx = counter * 32 + i
if idx < len(ct):
out.append(ct[idx] ^ b)
print(bytes(out).decode())
PY
Final Result
I recovered the private key, verified it against the public key, and decrypted the flag:
tjctf{m1n3rv4_h34rd_th3_n0nc3_tick}
Key Takeaways
The attack chain here illustrates three practical lessons. First, timing measurements alone are enough to bias a nonce attack - no memory access patterns or power traces are needed. Second, five biased signatures were sufficient to recover a 256-bit private key because the nonce bound was generous (200 bits out of 256). In harder variants of this attack fewer signatures might be available and the bound might be tighter, requiring a larger lattice dimension. Third, the keystream derivation added an extra SHA-256 step, which is a reminder to always read the actual encryption code rather than assuming a standard construction.
forensics/unfinished-file
Flag
tjctf{n3v3r_l3t_0ther_p30ple_t0uch_ur_c0mputer}

Challenge Description
The challenge presented an unfinished download file (secret_archive.zip.crdownload) with the description which will act as our hint: “my stupid friend tried downloading this file before i shut my laptop down, what was he trying to do? flag format is tjctf{…}”. The objective was to recover the flag from this partial download.
Phase 1: Initial File Examination and Structure Analysis
Understanding .crdownload Files
A .crdownload file is a temporary file created by Google Chrome (and other Chromium-based browsers) when a download is in progress. When Chrome starts downloading a file, it writes to a .crdownload temporary file and appends the actual downloaded bytes to it as they arrive from the server. When the download finishes successfully, Chrome renames the file to its original extension. If the download is interrupted - by closing the laptop, killing the browser, or losing the connection - the .crdownload file is left behind with however much data was received before the interruption, along with a small metadata header written by Chrome at the start of the file.
This means a .crdownload file often contains real, usable partial content from the download, embedded after the metadata header. In this challenge, the download was far enough along that a complete ZIP archive was embedded inside the partial file.
File Type and Size Check
First, I examined the file type and size using ls -lh and file commands to get an initial understanding of the provided attachment.
ls -lh secret_archive.zip.crdownload
file secret_archive.zip.crdownload
Output:
-rw-r--r-- 1 ubuntu ubuntu 463 May 16 16:05 secret_archive.zip.crdownload
secret_archive.zip.crdownload: data
The output indicated that the file was 463 bytes in size and identified as generic data, which is expected for a raw, partially downloaded file that has not been fully recognized by the file utility. The file utility identifies files by magic bytes at fixed offsets, but the .crdownload format places the Chrome metadata header before the actual file data, so the magic bytes are not at offset zero.
Hexdump Analysis
To understand the internal structure and identify any recognizable patterns, I used xxd to view the hexadecimal and ASCII representation of the file’s content. Initially, I attempted to use hexdump -C, but hexdump was not available, so I switched to xxd.
xxd secret_archive.zip.crdownload | head -n 20
Partial Output:
00000000: 4352 444c 0100 0000 6b04 0000 0000 0000 CRDL....k.......
00000010: 2600 6874 7470 733a 2f2f 6578 616d 706c &.https://exampl
00000020: 652e 636f 6d2f 7365 6372 6574 5f61 7263 e.com/secret_arc
00000030: 6869 7665 2e7a 6970 0000 0000 0000 0000 hive.zip........
...
00000100: 504b 0304 1400 0000 0000 0000 0000 ce9e PK..............
00000110: b06e 2900 0000 2900 0000 0a00 0000 7265 .n)...).......re
00000120: 6164 6d65 2e74 7874 5468 6973 2066 696c adme.txtThis fil
00000130: 6520 6973 2069 6e63 6f6d 706c 6574 652e e is incomplete.
00000140: 204b 6565 7020 6c6f 6f6b 696e 672e 2e2e Keep looking...
00000150: 0a50 4b03 0414 0000 0000 0000 0000 003d .PK............=
00000160: e22e cb2f 0000 002f 0000 0010 0000 0068 .../.../.......h
00000170: 6964 6465 6e2f 2e66 6c61 6764 6174 6136 idden/.flagdata6
00000180: 2821 3624 392c 7134 7130 1d2e 7136 1d72 (!6$9,q4q0..q6.r
00000190: 362a 2730 1d32 7172 322e 271d 3672 3721 6*\'0.2qr2.\'.6r7!
000001a0: 2a1d 3730 1d21 722f 3237 3627 303f 504b *.70.!r/276\'0?PK
000001b0: 0102 1400 1400 0000 0000 0000 0000 ce9e ................
000001c0: b06e 2900 0000 2900 0000 0a00 0000 00 .n)...)........
Key observations from the hexdump:
CRDL Header: The file starts with 4352 444c which translates to CRDL in ASCII. This is the magic signature for Chrome’s .crdownload files. This confirms the file type. [1]
Embedded URL: At offset 0x14, an embedded URL https://example.com/secret_archive.zip is visible. This confirms that the original file was intended to be a ZIP archive and tells us what the server was serving.
ZIP Signatures: At offset 0x100 and 0x150, the bytes 504b 0304 appear, which is the local file header signature for a ZIP archive (PK in ASCII, from the initials of Phil Katz who created the format). This indicates that the .crdownload file contains actual, parseable ZIP data starting at offset 0x100.
File Entries: Following the first ZIP signature, the filename readme.txt is visible, along with the text “This file is incomplete. Keep looking…”. This is a clear misdirection hint embedded by the challenge author to point solvers toward the second entry.
Second File Entry: After the first entry, another ZIP local file header (PK) is found at offset 0x150, followed by the filename hidden/.flagdata. This is highly suspicious and is the entry that contains the flag.
Obfuscated Data: Immediately after hidden/.flagdata, there is a sequence of bytes that looks like obfuscated data:
3628213624392c713471301d2e71361d72362a27301d327172322e271d367237212a1d37301d21722f32373627303f
This is the encoded flag content.
Phase 2: Data Recovery and Flag Extraction
Given the tjctf{...} flag format and the obfuscated data, a common CTF technique is XOR encryption with a single byte key. XOR encryption with a single key byte simply XORs every byte of the plaintext with the same constant. If even one plaintext byte is known, the key can be recovered immediately by XORing that known plaintext byte with the corresponding ciphertext byte.
The character t in tjctf{ has an ASCII value of 0x74. The first byte of the obfuscated data is 0x36. If I assume the first byte of the obfuscated data corresponds to t, I can find the XOR key:
0x74 (t) ^ KEY = 0x36
KEY = 0x74 ^ 0x36 = 0x42
So the XOR key is 0x42. This is a known-plaintext attack: I know part of the plaintext (the flag prefix tjctf{), and I use that to derive the key.
I extracted the hexadecimal data corresponding to hidden/.flagdata and wrote a small Python script to XOR each byte with 0x42.
data = bytes.fromhex("3628213624392c713471301d2e71361d72362a27301d327172322e271d367237212a1d37301d21722f32373627303f")
flag = "".join(chr(b ^ 0x42) for b in data)
print(flag)
I saved this script as decode_flag.py and executed it.
python3 decode_flag.py
Output:
tjctf{n3v3r_l3t_0ther_p30ple_t0uch_ur_c0mputer}
The decoded string begins with tjctf{ and ends with }, which confirms the key guess was correct and the full flag has been recovered.
Phase 3: Conclusion
The flag was successfully recovered by analyzing the .crdownload file, identifying embedded ZIP data after the Chrome metadata header, extracting the obfuscated content of hidden/.flagdata, and decoding it using a single-byte XOR key derived from the known flag prefix.
Flag
tjctf{n3v3r_l3t_0ther_p30ple_t0uch_ur_c0mputer}
Key Takeaways
The two most important lessons here are about file formats and about XOR. On the file format side, a .crdownload file is not a corrupted or unreadable file - it is a well-structured container with a small metadata header followed by the actual downloaded bytes. Knowing to look past the header and search for embedded magic bytes (PK for ZIP) is the first step in most partial-download forensics problems. On the XOR side, single-byte XOR with a known plaintext prefix (a predictable flag format) is always trivially breakable. The moment an attacker knows even one byte of plaintext, the entire keystream is exposed.
References
.crdownload file format: download_file_impl.cc
That was an interesting challenges and ctf in general.Hope you enjoyed the writeup.
HAPPY HACKING!!
Comments