· by WiredIn

LYKNCTF Writeups

Collection of writeups for LYKNCTF challenges by the team.

Misc

World cup 1

I started with using basic stenography commands like file, strings, zsteg, exiftool.

Doing exiftool showed a hint written which said to look in Red bits more carefully. So, I went to AperiSolve thinking that the flag might be written and can only be seen in the Red bits of the photo. As I found nothing there, I decided to look into the red bits row by row and luckily I found the flag written in the LSB of the red bit in the first row.

The flag for the challenge was:

LYKNCTF{Argentina3-2CaboVerde}

World cup 2

Similarly to the earlier challenge I tried basic commands first, there wasn’t anything interesting except the binwalk output tells that the image file has a zip file embedded.

So, I searched for the zip file signature PK and got the offset. The zip file was not having any encryption so, I easily got the flag_hidden.txt file in it.

The flag:

LYKNCTF{RespectToCaboVerde}

Tanh Hoa 1

The challenge gave a .mp4 file which looked like it was a song. I tried the basic commands first:

file lyknctf.mp4
ffprobe lyknctf.mp4

Their output tells that the file was a valid MP4 with normal video and audio streams.

strings -a lyknctf.mp4 | grep -i flag

The output showed a flag.txt was hidden inside the file. This looked sus so, I looked for the zip file that may contain this flag.txt. So I searched for zip signatures:

grep -aob 'PK' lyknctf.mp4

Using this I found the offset and then got the zip file, but when I tried extracting it, it asked for a password. First, I tried cracking it with rockyou.txt but it didn’t give any correct password, and then looked into the binaries and the audio of the video file. Doing basic steganography and looking into the spectrogram of the audio file showed some text:

RAUMAPHATAU

Which turned out to be the password for the .zip file.

The flag:

LYKNCTF{NGU01_TH4NH_H04_4N_R4U_M4_PH4_DU0NG_T4U}

Tanh hoa 2

Just like in the earlier challenge, it had a .zip file with the flag.txt in it, but the password wasn’t in the audio.

When I tried file and ffprobe commands, it showed that the MP4 had normal video and audio streams, but ffprobe showed one extra interesting file — there was some attached PNG file. The image didn’t give any password, so I checked its LSB and found some readable text:

NEMCHUATHANHHOA

Which again turned out to be the password of the .zip file.

The flag:

LYKNCTF{N3M_CHU4_TH4NH_H04_D4C_S4N_XU_TH4NH}

backroom pathfinding

The challenge gave two Backrooms images. The first image was the start level Level 0 and the second image represented the target level Level 99.

The goal was to find a path from Level 0 to Level 99 using paths that can be taken as described in the Backrooms Wiki pages. Each Backrooms level is a node, and a transition from one level to another is an edge.

Talking to the author gave me the hint that this could be solved if we looked at them as a graph. So, I tried it, and once the transitions were converted into a graph, I found the path easily using BFS.

The flag:

LYKNCTF{012499}

Remedy

Category: Miscellaneous / Forensics

This is one of the basic challenges for hiding data in the form of metadata of an image.

Challenge description for Remedy Challenge attachment for Remedy

Approach and Solution

Used the mentioned below commands for the retrieval of the flag:

  1. exiftool challenge.png — used this command for getting all the metadata. Found the description a bit suspicious: 6d14166842b6ecb67622284a65bde8a87e03344564bde3ab7e1e324b648dc4a87e0a2f4976bdffbd7e0233435ea6cbb45c.
  2. Thought of XOR encryption, using the first 8 blocks of the text as the XOR for the letter LYKNCTF, found a repeating pattern and the key: 21 4d 5d 26 01 e2 aa cd.
  3. Applying that exact 8-byte key across the rest of the hexadecimal string reveals the plaintext message block-by-block:
BlockHexPlaintext
16d14166842b6ecb6LYKNCTF{
27622284a65bde8a8Would_Be
37e03344564bde3ab_Nice_If
47e1e324b648dc4a8_Someone
57e0a2f4976bdffbd_Grow_Up
67e0233435ea6cbb4_One_Day
Final byte5c}

Flag: LYKNCTF{Would_Be_Nice_If_Someone_Grow_Up_One_Day}

Echoes

misc / forensics · 500 pts

LYKNCTF{0rph4n3d_c0mm1t5_l1v3_f0r3v3r}

The setup

All we get is a GitHub repo link and a very smug README:

Nothing to see here. I already deleted everything.

Cloning it backs that up — one commit, one README, nothing else. git fsck, git reflog, ls-remote — all empty. If something was deleted, it’s not in the clone.

Where’d it go?

The classic move here is “check the events for force-pushes / deleted branches.” So I did — thoroughly:

  • Repo Events API → just a CreateEvent and some stars.
  • Repo /activity (the authoritative log of ref changes) → a single branch_creation. No force-push, no branch deletion, ever.
  • GH Archive (permanent public-event archive) → scanned every hour around the repo’s birthday. Same two events. Nothing else.

So the flag was never pushed to a branch and rewritten. Dead end for the usual approach.

But here’s the thing about GitHub: GitHub doesn’t delete commits, it de-references them. If a commit object ever existed, it still answers when you ask for it by hash — even with no branch pointing at it. The problem: I don’t have the hash.

Finding a hash

GitHub happily resolves a commit from a short SHA — as few as 4 hex characters. That’s only 16^4 = 65,536 possibilities. Brute-forceable… but REST is rate-limited to death.

Enter GraphQL. object(expression: "abcd") resolves an object by short SHA, returns null on a miss, and — crucially — you can stuff hundreds of aliased lookups into one query:

{ repository(owner:"datxmilanista-png", name:"echoes"){
    a0000: object(expression:"0000"){ __typename oid ... on Commit { message } }
    a0001: object(expression:"0001"){ ... }
    # ... 500 per request
}}

Sweep the whole 4-hex space in ~130 requests. Out pops a commit that isn’t the one we already knew about:

Commit 262a1202...  "experimenting with encoding stuff (wip, do not share)"

An orphaned commit, child of main, that no ref points to. Found it.

The payload

The commit adds experiment.py:

_raw = "d727336733270366f5336713c6f5534713d6d60336f54633e64386072703b7644534e4b495c4"

def _recover(s):
    return bytes.fromhex(s[::-1]).decode()

Reverse the hex string, decode the bytes (Ovaltine = decode-the-secret-message, cute):

LYKNCTF{0rph4n3d_c0mm1t5_l1v3_f0r3v3r}

Takeaway

“I deleted everything” is a lie on GitHub. Orphaned commits stick around and answer to their hash forever — and if you don’t know the hash, a batched GraphQL sweep of the short-SHA space finds it for you. The flag says it best: orphaned commits live forever.

CTFTIME

The challenge description says to check CTFTime. So, checking the organizer of LYKNCTF, one of the team members had the flag in their description.

Web

Right in front of your eyes

Simply viewing in Burp shows that it redirects to a random page — the original 302 response body had the flag.

Spawn Race

Title hints towards a race condition, and the source code shows WebSockets for spawning. Sending multiple WS messages in parallel using asyncio gave the flag.

{"type":"spawned","image":"/images/1.gif","sound":"/sounds/4.mp3","spawnId":6,"race":"won","flag":"LYKNCTF{b2e0b77bf4ee43d5a3688e1bc7271a8e}"}

Discord Nitro

Login with given guest creds, the page directly hints towards a JWT token — forge a new token with role admin and set alg to none, then visit the admin panel.

FU Career

First, request an admin password reset. OTPs are 4 digits with no rate limiting, so bruteforce it and change the password to access the admin account. The admin panel has the first part of the flag.

Admin also has access to the /preview.php endpoint which is vulnerable to SQL injection. The MySQL user ctf has write access, and the /uploads directory had 777 perms so everyone could read, write, execute in it. part2.txt was owned by root, but the Dockerfile sets SUID on /usr/bin/csvtool. So upload a PHP shell using SQLi to get remote code execution, and use the SUID binary to read the flag.

That was the solution locally, but on the remote instance there was no user named admin. The actual admin privileged username had to be enumerated from the page, and it was actually hidden in the footer in the mailto links.

Crypto

FuOverflow learning app

It had:

fuoverflow_learning.exe
726471288_122216388452484307_639451856029278247_n.enc.bin

The .enc.bin file looked encrypted. The extracted exe file was a Rust Windows application. Running strings gave these:

FIXED_ENCRYPTION_KEY
FUO_PASS_SECRET
HMAC verification failed
FixedEnvelope
encrypted_fixed
iv
data

I found something like a key in the binary:

H}3t%^nDw5F?cWj-XAH!Dj8AakaD9y9M

The encrypted file format was:

[12-byte header][ciphertext]

The 12-byte header was:

00 11 22 33 44 55 66 77 00 00 00 07

This was used as the counter material. The algorithm was AES-256-CTR.

  • Key: H}3t%^nDw5F?cWj-XAH!Dj8AakaD9y9M
  • IV/counter: 001122334455667700000007

After decrypting it there was an image of a pokemon. So the flag was:

LKYNCTF{alolan_vulpix}

Twelve Steps

The given generator is a Linear Congruential Generator (LCG):

sn+1=(a⋅sn+c) mod ms_{n+1} = (a \cdot s_n + c) \bmod m

We are given 12 consecutive outputs and need to find the 13th. This is breakable because we have more outputs than unknowns, and they can be solved like a system of equations.

Step 1. Kill c by subtracting

sn+1=a⋅sn+cs_{n+1} = a \cdot s_n + c sn+2=a⋅sn+1+cs_{n+2} = a \cdot s_{n+1} + c

Define the differences dn=sn+1−snd_n = s_{n+1} - s_n. We get:

dn+1=a⋅dn(modm)d_{n+1} = a \cdot d_n \pmod m

Step 2. Kill a to get multiples of m

Since a is constant, the ratio dn+1/dnd_{n+1}/d_n is the same for every n. So we have:

dn+1dn=dn+2dn+1(modm)\frac{d_{n+1}}{d_n} = \frac{d_{n+2}}{d_{n+1}} \pmod m

Cross-multiply to get rid of division:

dn+12≡dn⋅dn+2(modm)d_{n+1}^2 \equiv d_n \cdot d_{n+2} \pmod m

Rearrange to one side and define:

tn=dn+1⋅dn−1−dn2t_n = d_{n+1} \cdot d_{n-1} - d_n^2

Every tnt_n is ≡0(modm)\equiv 0 \pmod m, i.e. m divides every tnt_n. Now we have a bunch of numbers that are all multiples of m.

Step 3. Recover m with GCD

If m divides several integers, it also divides their greatest common divisor:

m=gcd⁡(t1,t2,t3,… )m = \gcd(t_1, t_2, t_3, \dots)

Why this lands on m almost always and not a big multiple of it is because each multiple tnt_n is effectively kn⋅mk_n \cdot m for some random knk_n. The GCD of a few random integers is almost always 1, so with enough samples, the gcd converges to m. If it lands on a small multiple like 2m2m, it is caught at the verification step and divided out.

Step 4. Find a, and then c

Once m is known, everything is a linear equation mod m. We have:

a=(s2−s1)⋅(s1−s0)−1(modm)a = (s_2 - s_1) \cdot (s_1 - s_0)^{-1} \pmod m c=(s1−a⋅s0)(modm)c = (s_1 - a \cdot s_0) \pmod m

Step 5. Verify and predict

Sanity-check that (a⋅si+c) mod m=si+1(a \cdot s_i + c) \bmod m = s_{i+1} for all 12 outputs. If any fails, your m was a multiple of the true modulus (factor it down). All cases passing means we have recovered the exact generator, so:

out[12]=(a⋅out[11]+c) mod mout[12] = (a \cdot out[11] + c) \bmod m

67xbet

The bookie’s “random” is JavaScript’s Math.random(), which in Chrome/Node runs an algorithm called xorshift128+. It looks random but it isn’t. The whole thing is driven by a hidden internal state, and if you can recover that state, you can predict every number it will ever spit out.

We are shown 5 numbers each round and need to guess the 6th. This is breakable because each number leaks a big chunk of the internal state, and 5 of them leak more than enough to pin it down completely.

Step 1. Know what the state is

xorshift128+ keeps a 128-bit state, which is just two 64-bit numbers, state0 and state1. Every time you call Math.random() it scrambles this state with a fixed sequence of XORs and bit shifts, then turns part of the new state into a decimal between 0 and 1. Same state in, same numbers out, forever. So the only thing keeping us from knowing the 6th number is state0 and state1.

Step 2. Each output leaks 52 bits

A decimal like 0.8228912... is stored as a double, and the fractional part (the mantissa) is 52 bits. V8 builds the random number by dropping the top 52 bits of a state word straight into that mantissa. That means we can run it backwards: unpack the double, pull out the mantissa, and we’ve recovered 52 bits of the state from a single number. We see 5 numbers, so:

5×52=260 known bits5 \times 52 = 260 \text{ known bits}

The whole state is only 128 bits. We have almost double the information we need.

Step 3. Solve for the state with Z3

The reason this works so cleanly is that xorshift128+ only uses XOR and shifts, which are linear operations. Linear means every output bit is just a fixed combination of the unknown state bits, so the whole thing is a big system of equations. Handing them to Z3, a solver, we get the state0 / state1 that fit. That pair is the secret state.

Step 4. Run it forward one more step

Once we have the real state, we own the generator. We just run the algorithm one more step ourselves and read off the 6th number before the server reveals it.

One thing to keep in mind is that V8 generates numbers in batches and hands them out in a slightly reordered way, so depending on how the challenge samples them, the next number can be one of two values. The two candidates come from the fact that V8 hands out its batch of random numbers in reverse order, so the next number is either the one generated just before the shown block or just after. Since the challenge allowed multiple attempts, we just compute both candidates and try them — whichever the server accepts is the answer, and it stays consistent for every round after that.

Step 5. Predict

Feed the 5 shown numbers into the solver, recover the state, generate the 6th, submit it (trying both candidates if needed), and we get the flag.

Noisy Broadcast

Vulnerability

RSA is used with a small public exponent e = 3, and the ciphertext is a noisy version of the encrypted plaintext. Since the ciphertext is extremely close to the cube of the original message, taking the integer cube root directly recovers the plaintext. The injected noise is small enough that it only affects the final byte of the recovered message, leaving the rest of the flag intact.

Solution

from Crypto.Util.number import long_to_bytes
from sympy import integer_nthroot

c = 258513173341110907855004634578328776675613337727374937778021308566776511394028586169719647601517686407530370600703671047834514223488817495300633613007122903215194800830817082508335094056353114537752319982589386027924378028160153097890317313131416661071211651623002925590879169419712047717

m, _ = integer_nthroot(c, 3)

print(long_to_bytes(m))

Output

b'LYKNCTF{n01sy_CRT_w1th_K4nn4n_3mb3dd1ng|'

The final byte is corrupted due to the injected noise, so the intended flag is:

LYKNCTF{n01sy_CRT_w1th_K4nn4n_3mb3dd1ng}

crypto/whispering

The objective of this challenge was to decrypt an AES-encrypted flag. In the source code, the AES encryption key was derived from a secret value called the algebraic signature V, which is calculated by multiplying two secret polynomials (f and g), and then summing all the coefficients of the resulting polynomial.

However, we don’t need to actually multiply f and g, since the sum of the coefficients of the product of 2 polynomials is the product of the sums of their coefficients. The server also leaked some side channel info, namely the sums of the even and odd coefficients of both f and g mod 127.

f and g were generated such that their coefficients could only be -1, 0 or 1. Since the degree of the polynomials is at most 127, the sums of odd or even coefficients is between -64 and 64. This allows us to find the exact odd and even sums, which allows us to find the sum of coefficients of both f and g as well as that of their product.

This revealed the algebraic signature V, which we can pass into the key derivation function they provided to retrieve the AES key and decrypt the flag.

crypto/postbox

Classic AES-CBC padding oracle attack. A login service issued AES128 CBC session tokens (iv + ciphertext from GET /login) along with a POST /decrypt endpoint that leaked PKCS7 padding validity, allowing each byte to be bruteforced.

The /decrypt endpoint, given an iv and ciphertext, returns {"ok": true} if the padding is correct, and {"error": "bad padding"} otherwise.

We have the decryption formula Pi=D(Ci)⊕Ci−1P_i = D(C_i) \oplus C_{i-1}. By controlling the “previous block” (sent as iv against a single target ciphertext block) we can recover each byte of D(Ci)D(C_i) via the oracle and XOR with the real previous block to get plaintext, repeated for all 6 blocks.

Since this takes some time and in this CTF the challenge instances run for only 600 seconds, one can either save the current state to a file to avoid losing progress, or sit there and manually renew the instance periodically.

Hash & Dash

Category: Cryptography

A cryptographic challenge asking for login as the admin and getting the hidden information the admin has — a classic JWT forgery / hash length-extension challenge.

Challenge description for Hash & Dash

The vulnerability found in this challenge:

  1. Hash length-extension attack — The server authenticated messages via simple concatenation of a secret key in front of the message and hashed the whole thing, treating the result as the signature. Hence, we needed to find a correct hash to gain admin access.
  2. SHA-256 is a Merkle–Damgård construction, and doesn’t hash the message in one shot:
    • Pads the message with a 0x80 byte, some zero bytes, and an 8-byte length field (to a multiple of 64 bytes).
    • Splits it into 64-byte blocks.
    • Processes each block, updating an internal 256-bit state (8×32-bit words).
    • The final output is that raw internal state — nothing is hidden or mixed in afterward.
  3. Hence, if we already know a valid SHA256(secret || message), we know the exact internal state of the hash function after it finished processing secret || message. We can resume hashing from that state and feed in more data — we never need to know the secret itself, only its length (so we can correctly compute where the padding goes).
  4. We need the length of the secret to do the correct padding, matching the server’s SHA-256 64-bit padding.

Approach and Solution

With the help of an LLM, generated the code for the length-extension attack:

import socket, struct, json, re
import sys

HOST = sys.argv[1] if len(sys.argv) > 1 else "51.79.140.18"
PORT = int(sys.argv[2]) if len(sys.argv) > 2 else 15191

# ---------- pure-python SHA-256 internals ----------
K = [
    0x428a2f98,0x71374491,0xb5c0fbcf,0xe9b5dba5,0x3956c25b,0x59f111f1,0x923f82a4,0xab1c5ed5,
    0xd807aa98,0x12835b01,0x243185be,0x550c7dc3,0x72be5d74,0x80deb1fe,0x9bdc06a7,0xc19bf174,
    0xe49b69c1,0xefbe4786,0x0fc19dc6,0x240ca1cc,0x2de92c6f,0x4a7484aa,0x5cb0a9dc,0x76f988da,
    0x983e5152,0xa831c66d,0xb00327c8,0xbf597fc7,0xc6e00bf3,0xd5a79147,0x06ca6351,0x14292967,
    0x27b70a85,0x2e1b2138,0x4d2c6dfc,0x53380d13,0x650a7354,0x766a0abb,0x81c2c92e,0x92722c85,
    0xa2bfe8a1,0xa81a664b,0xc24b8b70,0xc76c51a3,0xd192e819,0xd6990624,0xf40e3585,0x106aa070,
    0x19a4c116,0x1e376c08,0x2748774c,0x34b0bcb5,0x391c0cb3,0x4ed8aa4a,0x5b9cca4f,0x682e6ff3,
    0x748f82ee,0x78a5636f,0x84c87814,0x8cc70208,0x90befffa,0xa4506ceb,0xbef9a3f7,0xc67178f2]

def rrot(x, n):
    return ((x >> n) | (x << (32 - n))) & 0xffffffff

def sha256_pad(msg_len_bytes):
    ml_bits = msg_len_bytes * 8
    pad = b'\x80'
    pad_len = (56 - (msg_len_bytes + 1) % 64) % 64
    pad += b'\x00' * pad_len
    pad += struct.pack('>Q', ml_bits)
    return pad

def sha256_compress(chunk, h):
    w = list(struct.unpack('>16L', chunk)) + [0]*48
    for i in range(16, 64):
        s0 = rrot(w[i-15],7) ^ rrot(w[i-15],18) ^ (w[i-15] >> 3)
        s1 = rrot(w[i-2],17) ^ rrot(w[i-2],19) ^ (w[i-2] >> 10)
        w[i] = (w[i-16] + s0 + w[i-7] + s1) & 0xffffffff

    a,b,c,d,e,f,g,hh = h
    for i in range(64):
        S1 = rrot(e,6) ^ rrot(e,11) ^ rrot(e,25)
        ch = (e & f) ^ ((~e & 0xffffffff) & g)
        t1 = (hh + S1 + ch + K[i] + w[i]) & 0xffffffff
        S0 = rrot(a,2) ^ rrot(a,13) ^ rrot(a,22)
        maj = (a & b) ^ (a & c) ^ (b & c)
        t2 = (S0 + maj) & 0xffffffff
        hh, g, f = g, f, e
        e = (d + t1) & 0xffffffff
        d, c, b = c, b, a
        a = (t1 + t2) & 0xffffffff
    return [(x + y) & 0xffffffff for x, y in zip(h, [a,b,c,d,e,f,g,hh])]

def length_extend(orig_tag_hex, orig_total_len, extra):
    h = list(struct.unpack('>8L', bytes.fromhex(orig_tag_hex)))
    glue = sha256_pad(orig_total_len)
    new_len_before_extra = orig_total_len + len(glue)
    data = extra + sha256_pad(new_len_before_extra + len(extra))
    for i in range(0, len(data), 64):
        h = sha256_compress(data[i:i+64], h)
    new_tag = b''.join(struct.pack('>L', x) for x in h).hex()
    return new_tag, glue

# ---------- networking ----------
def recvline(sock):
    buf = b''
    while not buf.endswith(b'\n'):
        chunk = sock.recv(1)
        if not chunk:
            break
        buf += chunk
    return buf.decode(errors='replace').strip()

def recv_all_available(s, timeout=2.0):
    """Read whatever the server sends until it pauses (no newline required)."""
    s.settimeout(timeout)
    chunks = []
    try:
        while True:
            chunk = s.recv(4096)
            if not chunk:
                break
            chunks.append(chunk)
            if chunk.endswith(b'> ') or chunk.endswith(b'\n'):
                s.settimeout(0.3)
                try:
                    more = s.recv(4096)
                    if more:
                        chunks.append(more)
                    else:
                        break
                except socket.timeout:
                    break
    except socket.timeout:
        pass
    return b''.join(chunks).decode(errors='replace')

def try_attack(secret_len, extra=b"&role=admin"):
    s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    s.settimeout(5)
    s.connect((HOST, PORT))

    banner = recvline(s)          # the JSON line
    _instructions = recvline(s)   # "Submit one JSON line with msg and tag."
    s.settimeout(0.5)
    try:
        s.recv(64)  # flushes "> "
    except socket.timeout:
        pass

    data = json.loads(banner)

    msg_hex = data["message_hex"]
    orig_tag = data["token"]
    orig_len = len(bytes.fromhex(msg_hex))

    total_len = secret_len + orig_len
    new_tag, glue = length_extend(orig_tag, total_len, extra)

    forged_msg_hex = msg_hex + glue.hex() + extra.hex()

    payload = json.dumps({"msg": forged_msg_hex, "tag": new_tag})
    s.sendall(payload.encode() + b"\n")

    resp = recv_all_available(s, timeout=2.0)
    s.close()
    return resp

def main():
    # Phase 1: discover secret length (skip if already known)
    KNOWN_SECRET_LEN = 16  # confirmed valid from prior run

    candidates = [
        b"&admin=true", b"&admin=1", b"&admin=True", b"&role=admin",
        b"&isadmin=true", b"&is_admin=true", b"&is_admin=1", b"&access=admin",
        b"&level=admin", b"&grant=admin", b"&privilege=admin", b"&user=admin",
        b"&user=admin&role=admin", b"&role=admin&admin=true",
        b"&role=administrator", b"&perm=admin", b"&permission=admin",
    ]

    for extra in candidates:
        resp = try_attack(KNOWN_SECRET_LEN, extra=extra)
        print(f"[extra={extra}] -> {resp.strip()[:200]}")
        if resp and '"admin": true' in resp.replace(" ", ""):
            print("\n[+] ADMIN GRANTED!")
            print(resp)
            return
        if resp and ("flag" in resp.lower() or re.search(r'[A-Za-z0-9_]+\{.*?\}', resp)):
            print("\n[+] FLAG FOUND!")
            print(resp)
            return

    print("\n[-] None of the candidate fields worked. Need to inspect parsing logic further.")

if __name__ == "__main__":
    main()

Ran with python3 solve.py 51.79.140.18 11240.

Flag: LYKNCTF{5c03b69c71014f67950b8296420d69cd}

Sleepless Machine

Category: Cryptography

A cryptographic challenge involving a vulnerability with the vectors and dimensions of a lattice.

Challenge description for Sleepless Machine

I solved this challenge with the help of an LLM. The challenge involves some fairly involved mathematics.

1. The extremely short vector (the LLL magnet)

When the server generated the public key h(x)≡g(x)⋅f−1(x)(modxN−1,Q)h(x) \equiv g(x) \cdot f^{-1}(x) \pmod{x^N - 1, Q}, it used polynomials where coefficients were strictly ternary ({−1,0,1}\{-1, 0, 1\}) with a very low density of non-zero terms (36%).

If you look at the Euclidean length (norm) of a typical random vector in this 254-dimensional lattice modulo Q=4093Q = 4093, its length is massive:

Average Vector Length≈N⋅Q≈127⋅4093≈46,125\text{Average Vector Length} \approx \sqrt{N \cdot Q} \approx \sqrt{127 \cdot 4093} \approx 46{,}125

However, the secret vector (f,g)(f, g) only contains tiny {−1,0,1}\{-1, 0, 1\} numbers. Its Euclidean length is:

Secret Vector Length=∑fi2+∑gi2≈2⋅127⋅0.36≈9.5\text{Secret Vector Length} = \sqrt{\sum f_i^2 + \sum g_i^2} \approx \sqrt{2 \cdot 127 \cdot 0.36} \approx 9.5

The secret vector is ~4,800 times shorter than the surrounding lattice. Because of this massive gap, lattice reduction algorithms like LLL and BKZ don’t even have to work hard — they act like a powerful magnet, snapping directly onto this ultra-short vector in just a few seconds.

2. The hall of mirrors (why raw LLL wasn’t enough)

If LLL finds the short vector so easily, why do we need progressive BKZ and precision tweaks? Because in a cyclic polynomial ring modulo xN−1x^N - 1, any cyclic shift (rotation) of the secret polynomial has the exact same Euclidean length:

∥f(x)∥=∥x⋅f(x)∥=∥x2⋅f(x)∥=⋯=∥x126⋅f(x)∥\|f(x)\| = \|x \cdot f(x)\| = \|x^2 \cdot f(x)\| = \cdots = \|x^{126} \cdot f(x)\|

When LLL reduces the lattice, it lands in a “hall of mirrors” — it will hand you one of the 127 possible cyclic rotations of f(x)f(x) (or its negative orientation −f(x)-f(x)), but it does not know which specific rotation the server originally generated.

Because the challenge derived the AES key using weighted_trace — a function that multiplies each coefficient by its index (i+1)(i + 1) — the key derivation is not shift-invariant. Using the wrong rotation from LLL’s output means the AES decryption tag check fails.

3. The GPS anchor (the even/odd parity leakage)

This is where the author’s intentional “leakage” (sevens_{even} and sodds_{odd}) defeats the hall of mirrors.

Because N=127N = 127 is odd, shifting a polynomial cyclically by 1 position swaps the indices of all coefficients:

  • What was at index 0 (even) moves to index 1 (odd).
  • What was at index 1 (odd) moves to index 2 (even).
  • What was at index 126 (even) wraps around to index 0 (even).

This means every one of the 127 rotations produces a unique, predictable fingerprint of even-indexed and odd-indexed coefficient sums. By checking the LLL candidate vector against the server’s leaked sums, the script acts like a GPS: it instantly filters out the 126 “mirage” rotations, locks onto the exact alignment the server used, and derives the correct AES key.

Approach and Solution

  1. First ran the solver locally to check correct execution of the logic, then ran it on the actual server.

Running the solver against the live server

Used the following code, generated with the help of an LLM, to solve the challenge:

import json
import socket
import sys
from random import Random as PyRandom
from Crypto.Cipher import AES
from challenge_gen import _derive_key, generate_instance
from ntru_core import sum_even_odd, weighted_trace
from sage.all import *

def safe_bkz(M, bs):
    """Runs BKZ using extended precision ('qd', 'dd', 'ld') to prevent Babai
    infinite loops. Never falls back to dangerous 53-bit float math!"""
    # 1. Try Sage's wrapper with non-hyphenated ('qd', 'dd') and hyphenated names
    for kw in ["fp", "float_type"]:
        for val in ["qd", "dd", "ld", "long double", "mpfr", "q-d", "d-d"]:
            try:
                return M.BKZ(block_size=bs, **{kw: val})
            except Exception:
                continue

    # 2. Try calling fpylll directly if Sage wrapper strings fail
    try:
        from fpylll import BKZ as fpylll_BKZ, BKZParam, IntegerMatrix
        A = IntegerMatrix.from_matrix(M)
        for val in ["qd", "dd", "ld", "long double", "q-d", "d-d"]:
            try:
                param = BKZParam(block_size=bs, float_type=val)
                fpylll_BKZ.reduction(A, param)
                return A.to_matrix(Matrix(ZZ, M.nrows(), M.ncols()))
            except Exception:
                continue
    except Exception:
        pass

    # 3. SAFETY NET: NEVER call plain 53-bit M.BKZ() as it causes Babai crashes!
    # Instead, use exact NTL deep LLL (delta=0.9999), which achieves BKZ quality stably.
    print(" [!] High-precision BKZ flags unavailable. Using exact NTL Deep LLL...", flush=True)
    try:
        return M.LLL(delta=0.9999, algorithm="NTL:LLL")
    except Exception:
        return M.LLL(delta=0.9999)

def check_basis(M, se_f, so_f, se_g, so_g, N, Q, Q_PRIME, enc):
    """Scans the lattice basis, tests cyclic shifts, and verifies via AES decryption."""
    nonce = bytes.fromhex(enc["nonce"])
    ciphertext = bytes.fromhex(enc["ciphertext"])
    tag = bytes.fromhex(enc["tag"])

    for row in M:
        f_cand = list(row[:N])
        g_cand = list(row[N:])
        if all(c == 0 for c in f_cand):
            continue

        for sign in [1, -1]:
            f_try = [sign * x for x in f_cand]
            g_try = [sign * x for x in g_cand]

            for shift in range(N):
                f_rot = [f_try[(i - shift) % N] for i in range(N)]
                g_rot = [g_try[(i - shift) % N] for i in range(N)]

                if (
                    sum_even_odd(f_rot, N) == (se_f, so_f)
                    and sum_even_odd(g_rot, N) == (se_g, so_g)
                ):
                    s_alg = weighted_trace(f_rot, g_rot, N, Q_PRIME)
                    key = _derive_key(s_alg, N, Q, Q_PRIME)
                    cipher = AES.new(key, AES.MODE_GCM, nonce=nonce)
                    try:
                        flag = cipher.decrypt_and_verify(ciphertext, tag)
                        return f_rot, g_rot, flag
                    except Exception:
                        continue
    return None, None, None

def solve_instance(public_instance):
    print("[*] Extracting parameters...", flush=True)
    params = public_instance["parameters"]
    N = params["N"]
    Q = params["q"]
    Q_PRIME = params["q_prime"]
    h = public_instance["public_key"]["h"]
    leakage = public_instance["leakage"]
    enc = public_instance["encrypted_flag"]

    se_f, so_f = leakage["f_even_sum"], leakage["f_odd_sum"]
    se_g, so_g = leakage["g_even_sum"], leakage["g_odd_sum"]

    print(f"[*] Building 2N-dimensional NTRU lattice (Dimension: {2*N})...", flush=True)
    M = Matrix(ZZ, 2 * N, 2 * N)
    for i in range(N):
        M[i, i] = 1
        for j in range(N):
            M[i, N + ((i + j) % N)] = h[j]
    for i in range(N):
        M[N + i, N + i] = Q

    # Step 1: Fast C++ LLL reduction (~3 seconds)
    print("[*] Step 1: Running fast C++ LLL reduction (~3-5 seconds)...", flush=True)
    M = M.LLL(delta=0.99)

    print("[*] Checking LLL basis for secret polynomial...", flush=True)
    f_secret, g_secret, flag = check_basis(M, se_f, so_f, se_g, so_g, N, Q, Q_PRIME, enc)

    # Step 2: Progressive BKZ with precision-locked safe_bkz (100% crash-proof)
    if flag is None:
        print("[*] Step 2: Starting high-speed precision-locked BKZ (~10-20s)...", flush=True)
        for bs in [15, 20, 25, 30]:
            print(f" -> Running safe BKZ with block_size={bs}...", flush=True)
            M = safe_bkz(M, bs)
            f_secret, g_secret, flag = check_basis(M, se_f, so_f, se_g, so_g, N, Q, Q_PRIME, enc)
            if flag is not None:
                print(f"[+] True secret polynomial confirmed at block_size={bs}!", flush=True)
                break

    if flag is None:
        print("[-] Failed to find verified target polynomial in BKZ basis.", flush=True)
        return

    print("\n" + "=" * 50, flush=True)
    print(f"[+] FLAG: {flag.decode()}", flush=True)
    print("=" * 50 + "\n", flush=True)

def test_local():
    print("[*] Running LOCAL verification test...", flush=True)
    rng = PyRandom(1337)
    test_flag = "LYKNCTF{test_local_lattice_reduction_success}"
    inst, _ = generate_instance(test_flag, rng)
    solve_instance(inst)

def connect_and_solve(host, port):
    print(f"[*] Connecting to {host}:{port}...", flush=True)
    try:
        with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
            s.settimeout(15)
            s.connect((host, port))
            data = ""
            while True:
                try:
                    chunk = s.recv(4096).decode("utf-8", errors="ignore")
                    if not chunk:
                        break
                    data += chunk
                    if "{" in data and "}" in data:
                        try:
                            json_start = data.find("{")
                            json_end = data.rfind("}") + 1
                            instance_data = json.loads(data[json_start:json_end])
                            break
                        except Exception:
                            continue
                except socket.timeout:
                    break

            if not data.strip():
                print("[-] Received empty response. Is the CTF instance expired?", flush=True)
                return

            json_start = data.find("{")
            json_end = data.rfind("}") + 1
            if json_start == -1 or json_end == 0:
                print("[-] No valid JSON found in server response:", flush=True)
                print(data, flush=True)
                return

            instance_data = json.loads(data[json_start:json_end])
            solve_instance(instance_data)
    except Exception as e:
        print(f"[-] Connection or parsing error: {e}", flush=True)

if __name__ == "__main__":
    # MODE 1: Local test disabled for live capture
    # test_local()

    # MODE 2: Active connection to your live CTF challenge server!
    HOST = "51.79.140.18"
    PORT = 11040  # <-- Make sure this matches your active port!
    connect_and_solve(HOST, PORT)

Flag: LYKNCTF{53a85fd9fd0742d798f539cbce6adf83}

Pwn

Definitely the Oldest tric

I first checked the binary using basic tools:

file chall
strings chall
objdump -d -Mintel chall

The program asks for the length of our buffer. It checks if the length is greater than 80. So normally, anything above 80 should be rejected. After the check, the program stores the length in only 1 byte.

So if we enter -1, the check passes because -1 <= 80. But when -1 is stored as one byte, it becomes 0xff = 255. So the program ends up reading 255 bytes into a smaller stack buffer. This gives us a stack buffer overflow.

Exploiting this, I got the flag:

LYKNCTF{If_y0u_can_s0lv3_Thi5_chall_Th3n_y0ur3_4n_4bs0lute_femb1}

Rev

Screenshot blocking app

For this challenge I first tried to analyse the binary in Ghidra:

  1. Open the Symbol Tree.
  2. Go to Imports.
  3. Search for screenshot / window-related Windows API functions.

While I was doing it I also searched Google for the command functions which control screenshot blocking and all that. From that I got to know about the function SetWindowDisplayAffinity. I tried it and by coincidence this was the correct flag.

LYKNCTF{setwindowdisplayaffinity}

Flower-Themed Esolang

As given in the challenge, I checked if this text somehow represents any esolang. The file contained many repeated tokens such as:

usami_shohei
waguri_kaoruko
yorita_ayato
tsumugi_rintaro
hoshina_subaru
natsusawa_saku
kaoru_hana

From this I figured out that these must represent some code for the esolang, and I started with Brainfuck since it’s famous and usually used in CTFs. Brainfuck has commands like:

+ - < > [ ] ,

There was no output command because the program only checks input. If the input is correct, it exits — otherwise it just loops forever, and I used this to map the commands.

After decoding it I just ran a loop checking byte by byte for the Brainfuck program — if it moved to the next input instruction that means the byte was correct. Doing this I found the flag.

LYKNCTF{K40RU_H4N4_W4_R1N_T0_S4KU}

serial.exe

The program opens a GUI window and asks for a serial. If the correct serial is entered, the program says that the serial itself is the flag.

First, I checked the file type:

file Serial.exe

Then I checked printable strings:

strings -a Serial.exe

Some useful strings were visible:

Serial format is LYKNCTF{ + 24 chars + }.
Serial Check - OK
Serial accepted!
That serial is your flag.

So from this we know that there are 8 characters for LYKNCTF{, 24 characters inside, and 1 character for }.

In the disassembler, the program reads the user input via GetDlgItemTextA. After reading the input, it checks the length:

cmp eax, 0x21

0x21 in decimal is 33, so the input must be exactly 33 characters long. Then it checks the prefix LYKNCTF{ and the last character }. So the input must look like LYKNCTF{..24 times}.

The 24 inner characters are checked one by one. For each character:

  1. The program uses the current character.
  2. It mixes it with a seed value.
  3. It does operations like addition, XOR, multiplication etc.
  4. It produces a 16-bit result.
  5. That result is compared with a target table stored inside the binary.

The target table contains 24 values, one for each character. The checker also calculates a seed from the program’s own .text section — this means the program hashes part of itself and uses that hash in the serial check. For this binary, the seed calculation gives:

seed0 = 0x613cdcaa
seed1 = 0xc499790f

One thing I noticed was that the result of one character is used while checking the next character. Because of this, we must solve the characters in order. For each position, we can brute-force all possible byte values from 0 to 255, run the same calculation as the binary, and compare the lower 16 bits with the target value. If it matches, that character is correct — save the full result and use it for the next position.

This is very fast because we only try 24 * 256 = 6144 possibilities.

So, after solving this the final flag was:

LYKNCTF{Dyn4m1c_0nly_LYKN_2026!!}

Waguri2

Category: Miscellaneous / Reverse Engineering

Given some sort of language that needs to be decrypted to find something meaningful.

Challenge description for Waguri2

Approach and Solution

  1. The challenge logic is related to Brainfuck, and we need to reverse engineer the logic.
  2. The attachment uses character names from the manga Kaoru Hana wa Rin to Saku (The Fragrant Flower Blooms With Dignity) as a direct substitution for Brainfuck commands. By analyzing the loops ([ and ]), pointer movements, and input positions, we can determine the exact translation table:

Translation table mapping manga character names to Brainfuck commands

  1. With the help of an LLM, wrote code to translate the tokens taken from the manga names into Brainfuck code and then ran it through a Brainfuck interpreter:
import sys

def translate_esolang(filename):
    # Mapping table from flower esolang tokens to standard Brainfuck symbols
    mapping = {
        "waguri_kaoruko": "+",
        "tsumugi_rintaro": "-",
        "usami_shohei": ">",
        "natsusawa_saku": "<",
        "yorita_ayato": "[",
        "hoshina_subaru": "]",
        "kaoru_hana": ","
    }

    try:
        with open(filename, "r") as f:
            tokens = f.read().split()
    except FileNotFoundError:
        print(f"Error: Could not find '{filename}' in this directory.")
        sys.exit(1)

    # Translate valid tokens, ignoring unexpected words
    bf_code = "".join(mapping[token] for token in tokens if token in mapping)
    return bf_code

def run_brainfuck(bf_code):
    print("\n--- Running Brainfuck Program ---")
    print("Type your input string below when prompted.")

    # Brainfuck interpreter implementation
    memory = [0] * 30000
    ptr = 0
    code_ptr = 0
    input_buffer = ""

    # Precompute bracket pairs for fast jumping
    bracket_map = {}
    stack = []
    for i, char in enumerate(bf_code):
        if char == "[":
            stack.append(i)
        elif char == "]":
            start = stack.pop()
            bracket_map[start] = i
            bracket_map[i] = start

    # Main execution loop
    while code_ptr < len(bf_code):
        cmd = bf_code[code_ptr]

        if cmd == ">":
            ptr += 1
        elif cmd == "<":
            ptr -= 1
        elif cmd == "+":
            memory[ptr] = (memory[ptr] + 1) % 256
        elif cmd == "-":
            memory[ptr] = (memory[ptr] - 1) % 256
        elif cmd == ".":
            sys.stdout.write(chr(memory[ptr]))
            sys.stdout.flush()
        elif cmd == ",":
            if not input_buffer:
                # Ask for user input if buffer is empty
                input_buffer = input("Input character(s): ") + "\n"
            if input_buffer:
                memory[ptr] = ord(input_buffer[0])
                input_buffer = input_buffer[1:]
        elif cmd == "[":
            if memory[ptr] == 0:
                code_ptr = bracket_map[code_ptr]
        elif cmd == "]":
            if memory[ptr] != 0:
                code_ptr = bracket_map[code_ptr]

        code_ptr += 1
    print("\n--- Program Finished Gracefully ---")

if __name__ == "__main__":
    # 1. Translate the file
    bf_program = translate_esolang("output.txt")
    print("Successfully translated to Brainfuck!")
    print(f"Brainfuck code snippet (first 100 chars): {bf_program[:100]}...")

    # 2. Save translated file for external analysis if needed
    with open("translated.bf", "w") as f:
        f.write(bf_program)
    print("Saved clean code to 'translated.bf'.")

    # 3. Interactively run the code
    run_brainfuck(bf_program)
  1. Got all the translation into a file named translated.bf. The program asks for an input — if our input is correct, it prints a correct message; if not, it hangs in an infinite loop.

Running the translated Brainfuck program

  1. With the help of an LLM, generated a brute-forcing program: giving a correct letter makes it wait for the next letter, giving a wrong letter sends it into an infinite loop.
import sys

# 1. Load the clean Brainfuck code generated from your previous run
try:
    with open("translated.bf", "r") as f:
        bf_code = f.read().strip()
except FileNotFoundError:
    print("Error: Make sure 'translated.bf' is in this directory.")
    sys.exit(1)

# 2. Precompute bracket pairs for fast loop execution
bracket_map = {}
stack = []
for i, char in enumerate(bf_code):
    if char == "[":
        stack.append(i)
    elif char == "]":
        if stack:
            start = stack.pop()
            bracket_map[start] = i
            bracket_map[i] = start

def test_string(input_str):
    memory = [0] * 30000
    ptr = 0
    code_ptr = 0
    input_idx = 0
    instructions_run = 0
    max_instructions = 300000   # Cap execution to catch infinite loops on wrong answers

    while code_ptr < len(bf_code) and instructions_run < max_instructions:
        cmd = bf_code[code_ptr]
        instructions_run += 1

        if cmd == ">": ptr += 1
        elif cmd == "<": ptr -= 1
        elif cmd == "+": memory[ptr] = (memory[ptr] + 1) % 256
        elif cmd == "-": memory[ptr] = (memory[ptr] - 1) % 256
        elif cmd == ",":
            if input_idx < len(input_str):
                memory[ptr] = ord(input_str[input_idx])
                input_idx += 1
            else:
                # String is correct so far; it successfully reached the next prompt!
                return "WAITING"
        elif cmd == "[":
            if memory[ptr] == 0:
                code_ptr = bracket_map.get(code_ptr, code_ptr)
        elif cmd == "]":
            if memory[ptr] != 0:
                code_ptr = bracket_map.get(code_ptr, code_ptr)

        code_ptr += 1

        if code_ptr >= len(bf_code):
            return "FINISHED"
    return "FAILED"

# 3. Initialize with the known CTF flag prefix format
flag = "LYKNCTF{"
print(f"[+] Starting automated solve from: {flag}")

# Printable characters typical for CTF flags
charset = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_}"

while True:
    found_next = False
    for c in charset:
        res = test_string(flag + c)
        if res == "FINISHED":
            flag += c
            print(f"\n\n[SUCCESS] Flag found: {flag}")
            sys.exit(0)
        elif res == "WAITING":
            flag += c
            print(f"Discovering: {flag}", end="\r")
            sys.stdout.flush()
            found_next = True
            break

    if not found_next:
        print(f"\n[-] Stopped tracking. Last matched sequence: {flag}")
        break

Final brute-force output revealing the flag

Flag: LYKNCTF{K40RU_H4N4_W4_R1N_T0_S4KU}

Inferior Student

Category: Reverse Engineering

Given a chall.exe and challl.py file — a flag checker. If we enter a correct flag, we get back a correct message, else false. The catch is that everything in the code is written using Greek homoglyphs, and it checks the following:

  1. Anti-debug checks that all XOR into one state variable.
  2. That state variable becomes an AES-GCM key/IV. (AES only knows how to scramble a single 16-byte block; GCM is a mode of operation for using AES repeatedly to encrypt data of any length while also providing confidentiality and authentication.)

Challenge description for Inferior Student AES-GCM key/IV state derivation logic within the Greek-obfuscated code

  1. If the state is “clean” (0), it correctly decrypts a marshaled Python code object, which then gets exec()’d. Also found one command called after the AES/GCM decryption setup: exec(__f9b97b623dabbbc83fb3c__, ...).

Approach and Solution

With the help of an LLM, generated a solver for the flag:

#!/usr/bin/env python3
"""
Automated solver for the "Inferior Student" (LYKNCTF) style challenge.

Pipeline (all discovered manually first, automated here):

   challl.py (homoglyph-obfuscated, anti-debug)
     | exec(<code object>, globals) <- we dump this code object
     v
   stage2 code object (also homoglyph-obfuscated)
     | AES-GCM decrypts one or more blobs
     v
   a blob that starts with the zlib magic byte (0x78)
     | zlib.decompress -> marshal.loads
     v
   stage3 code object: builds a ChaCha20 cipher from a fixed key/nonce,
   encrypts input(), compares to a stored ciphertext constant.

Since ChaCha20 is a symmetric stream cipher, once we recover (key, nonce,
ciphertext) we don't need the correct input at all -- we just run the
cipher on the stored ciphertext to get the plaintext flag back.

Design notes on *why* no de-obfuscation of identifiers was needed:
  - Stage 1 -> Stage 2: we never read stage 1's logic. We locate the
    single `exec(NAME, ...)` call textually and splice in one line that
    dumps NAME via marshal *before* it runs. This changes no runtime
    behavior, so none of the anti-debug/anti-tamper checks (which key off
    things like hasattr(builtins.compile, '__code__')) are disturbed.
  - Stage 2 -> Stage 3: instead of reading 5000+ garbled names, we monkeypatch
    the *library* entry points the obfuscated code must eventually call
    (hashlib.sha256, Crypto.Cipher.AES.new, cryptography's Cipher.decryptor,
    and ChaCha20 construction) and let stage 2 run normally. We watch data
    flow through real crypto primitives instead of reading variable names.
  - Stage 3: small enough to read directly, but we still don't rename
    anything -- we identify key/nonce/ciphertext by *how* they're used
    (passed into ChaCha20(...), compared with ==) rather than by name.

Usage:
    python3 solve_inferior_student.py /path/to/challl.py
"""

import sys
import os
import re
import io
import zlib
import marshal
import types
import hashlib
import subprocess
import tempfile

FLAG_RE = re.compile(rb'[A-Za-z0-9_]{2,20}\{[ -~]{4,300}\}')

# ---------------------------------------------------------------------------
# Stage 1: splice a dump statement in front of the final exec(), run it once,
# and load the code object it would otherwise have exec()'d.
# ---------------------------------------------------------------------------

def extract_stage2_code_object(src_path):
    data = open(src_path, 'rb').read()

    # Find bare `exec(` calls (not `.exec(` / attribute access) at module scope.
    candidates = [m.start() for m in re.finditer(rb'(?<![.\w])exec\(', data)]
    if not candidates:
        raise RuntimeError("No exec( call found in source -- structure differs from expected.")

    # The loader call is the *last* exec( in the file in this challenge family.
    idx = candidates[-1]
    line_start = data.rfind(b'\n', 0, idx) + 1
    indent = data[line_start:idx]
    indent = indent[: len(indent) - len(indent.lstrip(b' \t'))]

    m = re.match(rb'exec\(([^\s,()]+)\s*,', data[idx:idx + 300])
    if not m:
        raise RuntimeError("Could not parse the code-object argument of exec(...).")
    varname = m.group(1)

    with tempfile.NamedTemporaryFile(delete=False, suffix='.marshal') as tf:
        dump_path = tf.name

    dump_stmt = (
        indent + b'__builtins__.open(' + repr(dump_path).encode() +
        b',"wb").write(__import__("marshal").dumps(' + varname + b'))\r\n'
    )
    patched = data[:line_start] + dump_stmt + data[line_start:]

    with tempfile.NamedTemporaryFile(delete=False, suffix='.py') as tf:
        patched_path = tf.name
        tf.write(patched)

    # Run it once, unmodified environment, feeding harmless input.
    # We don't care about the result -- we only want the dumped code object.
    subprocess.run(
        [sys.executable, patched_path],
        input=b"dummy_input_value\n",
        stdout=subprocess.DEVNULL,
        stderr=subprocess.DEVNULL,
        timeout=60,
    )
    os.unlink(patched_path)

    if not os.path.exists(dump_path) or os.path.getsize(dump_path) == 0:
        raise RuntimeError("Dump file was not produced -- exec target may not be a code object, "
                            "or anti-debug checks altered behavior in this environment.")

    with open(dump_path, 'rb') as f:
        code = marshal.load(f)
    os.unlink(dump_path)
    return code

# ---------------------------------------------------------------------------
# Stage 2+: monkeypatch crypto primitives, execute the code object, and
# recursively unwrap any AES-decrypted zlib+marshal payloads we find.
# Also capture every ChaCha20(key, nonce) construction we see.
# ---------------------------------------------------------------------------

class Captured:
    def __init__(self):
        self.aes_plaintexts = []     # bytes blobs decrypted via AES
        self.chacha_params = []      # (key, nonce) tuples
        self.other_plaintexts = []   # bytes blobs decrypted via generic `cryptography` Cipher

def run_with_crypto_taps(code_obj, captured: Captured, feed_input=b"dummy_input_value"):
    """exec() a code object with real crypto libraries wrapped so every
    decrypt call and every ChaCha20 construction is logged, without changing
    what the code actually computes. All patches are restored afterwards
    (try/finally) so nested/recursive calls never see stale patches."""
    restore_fns = []  # list of (obj, attr, original_value) to restore in finally

    # --- pycryptodome AES ---
    try:
        from Crypto.Cipher import AES as _AES
        real_aes_new = _AES.new
        restore_fns.append((_AES, 'new', real_aes_new))

        def tapped_aes_new(key, mode, *a, **kw):
            cipher = real_aes_new(key, mode, *a, **kw)
            real_decrypt = cipher.decrypt

            def tapped_decrypt(data):
                pt = real_decrypt(data)
                captured.aes_plaintexts.append(pt)
                return pt

            cipher.decrypt = tapped_decrypt
            return cipher

        _AES.new = tapped_aes_new
    except ImportError:
        pass

    # --- `cryptography` hazmat Cipher (used for both AES-GCM/CTR and ChaCha20
    # in this challenge family; we tap the constructor and decryptor().update).
    # NOTE: the returned CipherContext is a Rust object with no __dict__, so
    # we can NOT assign .update on it -- we wrap it in a thin Python proxy
    # instead of mutating the object itself. ---
    try:
        from cryptography.hazmat.primitives.ciphers import Cipher as _Cipher, algorithms as _algorithms
        real_chacha20_init = _algorithms.ChaCha20.__init__
        restore_fns.append((_algorithms.ChaCha20, '__init__', real_chacha20_init))

        def tapped_chacha20_init(self, key, nonce, *a, **kw):
            captured.chacha_params.append((bytes(key), bytes(nonce)))
            return real_chacha20_init(self, key, nonce, *a, **kw)

        _algorithms.ChaCha20.__init__ = tapped_chacha20_init

        real_decryptor = _Cipher.decryptor
        restore_fns.append((_Cipher, 'decryptor', real_decryptor))

        def tapped_decryptor(self):
            d = real_decryptor(self)
            real_update = d.update

            class _Wrapped:
                def update(inner_self, data):
                    pt = real_update(data)
                    captured.other_plaintexts.append(pt)
                    return pt

                def __getattr__(inner_self, name):
                    return getattr(d, name)

            return _Wrapped()

        _Cipher.decryptor = tapped_decryptor
    except ImportError:
        pass

    # Feed a dummy answer to any input() calls inside this stage.
    def tapped_input(*a, **kw):
        return feed_input.decode(errors='replace')

    orig_builtin_input = getattr(__builtins__, 'input', None)
    try:
        __builtins__.input = tapped_input
    except AttributeError:
        orig_builtin_input = None

    g = {'__name__': '__main__', '__builtins__': __builtins__}
    try:
        exec(code_obj, g)
    except SystemExit:
        pass
    except Exception:
        pass  # we only care about side-effect captures, not the checker's own output
    finally:
        for obj, attr, orig in restore_fns:
            try:
                setattr(obj, attr, orig)
            except Exception:
                pass
        if orig_builtin_input is not None:
            try:
                __builtins__.input = orig_builtin_input
            except Exception:
                pass

def find_flag_in_bytes_pool(pools, chacha_params):
    """Try decrypting every candidate ciphertext-looking bytes blob with every
    captured ChaCha20 (key, nonce) pair, and check for a FLAG{...}-shaped result."""
    from cryptography.hazmat.primitives.ciphers import Cipher, algorithms

    candidates = []
    for blob in pools:
        if isinstance(blob, (bytes, bytearray)) and 8 <= len(blob) <= 4096:
            candidates.append(bytes(blob))

    for key, nonce in chacha_params:
        for ct in candidates:
            try:
                c = Cipher(algorithms.ChaCha20(key, nonce), mode=None)
                pt = c.decryptor().update(ct)
            except Exception:
                continue
            m = FLAG_RE.search(pt)
            if m:
                return m.group(0), key, nonce, ct
    return None

def walk_code_consts_for_bytes(code_obj, acc):
    """Collect every bytes-like constant reachable from a code object,
    including nested lambda/genexpr code objects, as fallback candidates.
    Important: this obfuscator frequently does NOT store raw bytes constants
    in the bytecode. Instead it stores a tuple of ints and reconstructs the
    bytes object at runtime via `bytes([169, 136, 202, ...])`. So we also
    treat any plausible int-tuple (all values 0-255, reasonable length) as a
    candidate byte blob, not just literal bytes/bytearray constants."""
    for c in code_obj.co_consts:
        if isinstance(c, (bytes, bytearray)):
            acc.append(bytes(c))
        elif isinstance(c, types.CodeType):
            walk_code_consts_for_bytes(c, acc)
        elif isinstance(c, tuple) and 4 <= len(c) <= 4096:
            if all(isinstance(x, int) and 0 <= x <= 255 for x in c):
                acc.append(bytes(c))

def main():
    if len(sys.argv) != 2:
        print(f"Usage: {sys.argv[0]} <path-to-challl.py>")
        sys.exit(1)

    src_path = sys.argv[1]

    print("[*] Stage 1: locating final exec() and dumping the decrypted code object...")
    stage2_code = extract_stage2_code_object(src_path)
    print(f"    -> dumped stage-2 code object ({len(stage2_code.co_names)} names, "
          f"{len(stage2_code.co_consts)} consts)")

    captured = Captured()
    print("[*] Stage 2: executing with crypto taps installed (AES / ChaCha20 / Cipher)...")
    run_with_crypto_taps(stage2_code, captured)

    print(f"    -> captured {len(captured.aes_plaintexts)} AES plaintext blob(s), "
          f"{len(captured.chacha_params)} ChaCha20 key/nonce pair(s) so far")

    # Peel any zlib+marshal code objects out of what AES gave us, recursively.
    pending = list(captured.aes_plaintexts)
    all_bytes_pool = list(captured.aes_plaintexts)
    depth = 0
    while pending:
        depth += 1
        blob = pending.pop(0)
        if len(blob) > 2 and blob[0] == 0x78:  # zlib header
            try:
                dec = zlib.decompress(blob)
            except Exception:
                continue
            try:
                obj = marshal.loads(dec)
            except Exception:
                continue
            if isinstance(obj, types.CodeType):
                print(f"[*] Stage {depth + 2}: found nested code object under zlib -- executing with taps...")
                walk_code_consts_for_bytes(obj, all_bytes_pool)
                sub_captured = Captured()
                run_with_crypto_taps(obj, sub_captured)

                captured.chacha_params.extend(sub_captured.chacha_params)
                all_bytes_pool.extend(sub_captured.aes_plaintexts)
                all_bytes_pool.extend(sub_captured.other_plaintexts)
                pending.extend(sub_captured.aes_plaintexts)

    print(f"[*] Total ChaCha20 key/nonce pairs captured: {len(captured.chacha_params)}")
    print(f"[*] Total candidate ciphertext blobs to try: {len(all_bytes_pool)}")

    print("[*] Attempting to recover flag via direct ChaCha20 decryption "
          "(no brute force -- stream cipher is symmetric)...")
    result = find_flag_in_bytes_pool(all_bytes_pool, captured.chacha_params)

    if result is None:
        print("[!] Could not automatically recover the flag. Dumping captured "
              "material for manual inspection:")
        for i, (k, n) in enumerate(captured.chacha_params):
            print(f"    key[{i}] = {k.hex()}")
            print(f"    nonce[{i}]= {n.hex()}")
        sys.exit(2)

    flag, key, nonce, ct = result
    print()
    print("=" * 60)
    print(f"FLAG: {flag.decode()}")
    print("=" * 60)
    print(f" key    = {key.hex()}")
    print(f" nonce = {nonce.hex()}")
    print(f" ct     = {ct.hex()}")

    # Final sanity check: feed it into the *original*, unmodified script.
    print()
    print("[*] Verifying against the original, unmodified script...")
    proc = subprocess.run(
        [sys.executable, src_path],
        input=flag.decode().encode() + b"\n",
        capture_output=True,
        timeout=60,
    )
    out = proc.stdout.decode(errors='replace')
    print(f"    original script output: {out.strip()!r}")
    if 'Wrong' in out:
        print("[!] WARNING: original script rejected this flag. Investigate further.")
    else:
        print("[+] Flag verified against the original script.")

if __name__ == '__main__':
    main()

Final solver output confirming the recovered flag

Flag: LYKNCTF{Im_At_The_PayPhone_Tryin_To_Home_Allof_My_change_1_Spent_0n_u_Where_have_ThE_T1m3S_G0n3_B4bY_Its_Wr0nG_wh3rE_aRe_Th3_Pl4nS_W3_M4d3_F0r_2}

OSINT

Static

Category: Miscellaneous / OSINT

This is one of the challenges of analyzing a video and checking for hidden information mentioned in it, in the form of some sort of symbol from another language.

Challenge description for Static

Approach and Solution

  1. Watched the video at https://www.youtube.com/watch?v=KlTNKOnfXFk, and found that the character is using flag semaphore for giving a message.
  2. The video from 2:31–2:33 depicts a word that is being repeated in the next 3 second duration, as the challenge description said to grab the word that is repeated. The word was DONTGO.
  3. Used the following screenshots for detecting the word:

Semaphore screenshots and combined sequence

Flag: LYKNCTF{DONTGO}

Miss My School

Category: OSINT (Open Source Intelligence)

This is a classic OSINT challenge for finding the name of the school in which the author of the challenge used to study, with the image of the school shared as an attachment.

Challenge description for Miss My School Image of the school attached with the challenge

Approach and Solution

  1. Uploaded the picture on Google Images for finding similar images, found that the name of the school is Trường Tiểu học Long Biên.

Google Image search result confirming the school name

Flag: LYKNCTF{long_bien_elementary}

Unnamed Merchant

Category: OSINT (Open Source Intelligence)

This is a classic OSINT challenge for finding information about an object described online. In this challenge we have to find a civilian vessel mentioned in the records of AMSA’s public MH370 timeline records — the vessel that was closest to the Southern Indian Ocean during the Indian Ocean Search Phase.

Challenge description for Unnamed Merchant

Approach and Solution

  1. Used an LLM to help find information regarding the description and looked for reports mentioned online. Found all the required credentials for the flag.

LLM-assisted research into the AMSA MH370 timeline records

The link for the final report of the search: https://www.atsb.gov.au/sites/default/files/media/5773565/operational-search-for-mh370_final_3oct2017.pdf

Also used:

  • https://www.offshore-energy.biz/still-no-sign-of-the-missing-flight-mh370/
  • http://cbsnews.com/news/malaysia-airlines-flight-370-search-equipment-ranges-from-sophisticated-to-simple/
  • https://www.theguardian.com/world/2014/mar/20/mh370-two-possible-objects-may-have-been-found-in-australian-search-zone
  1. Formed all the credentials in the required order and got the flag:
    • Vessel Name — HOEGH_ST_PETERSBURG
    • IMO — 9420045
    • MMSI — 257366000
    • Number of crew — 19
    • Nationality — FILIPINO

Flag: LYKN{HOEGH_ST_PETERSBURG_9420045_257366000_19_FILIPINO}

Important Debris

Category: OSINT (Open Source Intelligence)

In this challenge we needed to find information about a debris report describing several pieces from Madagascar, where one item stands out because investigators could link a small marking to a Boeing cabin component.

Challenge description for Important Debris

Approach and Solution

  1. Used an LLM to find information regarding the description and looked for reports mentioned online.

Debris report referenced during the investigation, with URL Detail of the marking linked to the Boeing cabin component

  1. Formed all the credentials in the required order and got the flag:
    • Item — ITEM31
    • Marker — BAC27WPPS61
    • Specification — BMS4-20

Flag: LYKN{ITEM31_BAC27WPPS61_BMS4-20}

Route to Nowhere

Category: OSINT (Open Source Intelligence)

Given an image of a place in Hanoi, we need to find the nearest bus stop to this location.

Challenge description for Route to Nowhere Attached image of the place in Hanoi Additional reference images for the challenge

Approach and Solution

  1. Went to the VinBus website, found the route number mentioned on the bus in the first image, went to this route, and started looking for all the stops mentioned on the same route.

VinBus route lookup for the number identified on the bus

  1. Used the names of the stops on Google Maps, and looked for the pond or lake nearby — found the bus stop on the return route of Route 146.
  2. The name of the bus stop is Điếm Canh Đê Phù Dực.

Google Maps view of the identified bus stop

Flag: LYKNCTF{diem_canh_de_phu_duc}

Far Away

Category: OSINT (Open Source Intelligence)

A basic OSINT challenge for finding the name of the mountain close to the college whose image was given.

Challenge description for Far Away

The link for the image mentioned: https://drive.google.com/drive/folders/1zJ5tO_xUID-XnbLFJ5zbi6fe6SnHt17I

Approach and Solution

  1. Uploaded the image on Google Images and found the name of the college and the name of the mountain along with the height. Hence, got the flag.

Google Image search result identifying the college and the nearby mountain

Flag: LYKNCTF{ba_vi_1296m}

Far Away Revenge

Category: OSINT (Open Source Intelligence)

Continuation of the previous challenge — this time we needed to give details about the college, and how the image was captured from the hostel.

Challenge description for Far Away Revenge

The link for the image mentioned: https://drive.google.com/drive/folders/1zJ5tO_xUID-XnbLFJ5zbi6fe6SnHt17I

Approach and Solution

  1. Uploaded the image on Google Images and Google Gemini, found the details and the various permutations for the flag.

Google Image / Google Gemini result used to identify the building details

  1. The following credentials were required for the flag:
    • Building name — delta
    • Floor number — 4
    • Longitude — 21.01
    • Latitude — 105.52

Flag: LYKNCTF{delta_floor_4_21.01_105.52}

REDACTED FRAME

The folder name points to MH370 — the flight that went missing. Also, the link mentioned in the description is archived or deleted currently. So, using the Wayback Machine, found the PDF file which talks about the updates on the mission.

Another noteworthy detail is that the picture shows rscu74, which is the callsign of the “US Navy P8 Poseidon” mentioned in the PDF. So we now know that the PDF and video should be from the same mission report.

Since the PDF was dated 12 years ago, a YouTube search through the Australian Maritime Safety Authority channel for videos titled “MH370 March 20” gives the video where the picture was taken.

osint/town-tour-2

On searching the given image on Google Images and scrolling through the results, there is an image from a Vietnamese article that matches it almost completely. On translating the article to English we find that it is a VNPT (Vietnam’s state-owned telecom company) office in Pho Son Tay, Hanoi.

Opening the location in Google Street View confirms this. Looking for supermarkets near this office, we can find Lan Chi Mart located just a short distance away.

Flag - v1t{Lan_Chi_Mart}