January 13

Stegano Writeups

Preview

Write-ups for Stegano tasks by Kata. English is not my main language, so I might have problems with explanations.

babyStegano

There are snowflakes on the window, but I think there is something behind them.

babyStegano implies that this is indeed a simple task, so let's go to the zsteg and see the flag:

Flag: grodno{happy_new_year_ctfers}

UltraLastChristmas

Did you know that elves can even hear ultrasound?

This is also a very simple chall. The first thing I personally do when I see files in audio format is go to SonicVisualizer and look at the spectrogram.

Flag: grodno{laaaast_christmaaas_i_geiv_u_ma_ha}

Santa’s Report

We intercepted an encrypted report on Santa Claus’s activities.
All that remains is to determine his favorite day of the year.

We have an attached docs file that looks like the encoding is broken.

There is a row in the document highlighted in bold that looks like a flag.

I, as the author of the сhal, will not fix the encoding, since this line and the entire file is a Wikipedia article about Santa Claus and was left to distract you.

So let's move straight to the correct solution.

Some people may not know this, but DOCS and ODF formats are zip archives of XML files.
So let's just change the file extension to zip and see what's inside.

After searching through the files we can't find the flag.

We have only one image left in the folder Thumbnails

Let's open a hex editor and examine the image.

Flag: grodno{my_lovley_06_12}

Admin Vibes

I always try to be serious while creating tasks. Right?

Attached to the task is an audio file (my favorite song, I recommend everyone listen to it in full)

Danika House on YouTube)

This audio has an embedded image signature using LSB that can be extracted using a simple Python script.

Let's extract the image using binwalk:

Here is the photo itself:

Let's try zsteg again:

Flag: grodno{1'm_s@n3_!_pr0m1s3}

BinaryChess

In this chess game, a smart guy hid a flag. Find it. Pay attention to the moves. 0 and 1 are significant.

This chall was made using https://github.com/WintrCat/chessencryption
The encoding script reads the file as a bit stream and encodes them into chess moves.
In each position, it takes all legal moves, numbers them, and assigns each a binary number. The next portion of bits from the file selects which move to make. The sequence of moves is written to the PGN—this is the encoded data.

Decoder:

from time import time
from math import log2
from chess import pgn, Board
from util import get_pgn_games
def decode(pgn_string: str, output_file_path: str):
start_time = time()
total_move_count = 0
games: list[pgn.Game] = get_pgn_games(pgn_string)
with open(output_file_path, "w") as output_file:
output_file.write("")
output_file = open(output_file_path, "ab")
output_data = ""
for game_index, game in enumerate(games):
chess_board = Board()
game_moves = list(game.mainline_moves())
total_move_count += len(game_moves)
for move_index, move in enumerate(game_moves):
legal_move_ucis = [
legal_move.uci()
for legal_move in list(chess_board.generate_legal_moves())
]
move_binary = bin(
legal_move_ucis.index(move.uci())
)[2:]
if (
game_index == len(games) - 1
and move_index == len(game_moves) - 1
):
max_binary_length = min(
int(log2(
len(legal_move_ucis)
)),
8 - (len(output_data) % 8)
)
else:
max_binary_length = int(log2(
len(legal_move_ucis)
))
required_padding = max(0, max_binary_length - len(move_binary))
move_binary = ("0" * required_padding) + move_binary
chess_board.push_uci(move.uci())
output_data += move_binary
if len(output_data) % 8 == 0:
output_file.write(
bytes([
int(output_data[i * 8 : i * 8 + 8], 2)
for i in range(len(output_data) // 8)
])
)
output_data = ""
print(
"\nsuccessfully decoded pgn with "
+ f"{len(games)} game(s), {total_move_count} total move(s)"
+ f"({round(time() - start_time, 3)}s)."
)
if __name__ == "__main__":
PGN_FILE_PATH = "output.pgn"
OUTPUT_FILE_PATH = "decoded_output.bin"
with open(PGN_FILE_PATH, "r", encoding="utf-8") as pgn_file:
pgn_text = pgn_file.read()
decode(pgn_text, OUTPUT_FILE_PATH)
print(f"Decoded binary saved to: {OUTPUT_FILE_PATH} (from: {PGN_FILE_PATH})")

Аnd we get the flag: grodno{wh@t_d0_u_me@n_st3g0_1n_ch3ss}

Chupakabra

Under the influence of various New Year's treats and drinks, I came up with the idea to write my own implementation of /dev/urandom/. But I recorded some very important data with it, and now I can't get it back. Flag format inside the grodno file{a-z0-9}

The most terrifying task in my opinion. You can try typing

cat /dev/urandom in your terminal and see what happens.

I can't say what the author intended for this task, but I solved it with the help of AI.

The gist of my solver:

The program reads the file in chunks (block_size = 1 MiB) and calculates the MD5 hash for each block.

For each hash, it counts how many times it appears (counts) and stores up to four positions where the hash appears (positions).

It finds all hashes that appear exactly once—these are "singleton" blocks. The first such block is considered flag_hash (the supposed block with the flag) and its position is taken as flag_pos.

It looks for a "clean" block (clean_hash)—the most frequent hash in the file, different from flag_hash. It takes its position as clean_pos. The idea is that the file contains many identical (original) blocks and one different block, which contains the flag.

It reads the bytes of both blocks (read_block reads the block by index) and XORs them bitwise: xor_bytes = flag_block XOR clean_block.

The result is searched using a regular expression for the byte pattern b"grodno\{[a-z0-9]+\}"—that is, a string like grodno{...} with lowercase letters and numbers. If found, a flag is printed.

import hashlib
import os
import re
import sys


def read_block(path, index, size):
    with open(path, "rb") as f:
        f.seek(index * size)
        return f.read(size)


def main():
    path = sys.argv[1] if len(sys.argv) > 1 else "/home/marcus/Загрузки/challenge.bin"
    block_size = 1024 * 1024
    counts = {}
    positions = {}
    total_blocks = 0
    with open(path, "rb") as f:
        while True:
            data = f.read(block_size)
            if not data:
                break
            h = hashlib.md5(data).digest()
            counts[h] = counts.get(h, 0) + 1
            if len(positions.get(h, [])) < 4:
                positions.setdefault(h, []).append(total_blocks)
            total_blocks += 1
    singles = [h for h, c in counts.items() if c == 1]
    if not singles:
        print("no singleton block found")
        return
    flag_hash = singles[0]
    flag_pos = positions[flag_hash][0]
    clean_hash = max((h for h in counts if h != flag_hash), key=lambda k: counts[k])
    clean_pos = positions[clean_hash][0]
    flag_block = read_block(path, flag_pos, block_size)
    clean_block = read_block(path, clean_pos, block_size)
    xor_bytes = bytes(a ^ b for a, b in zip(flag_block, clean_block))
    m = re.search(b"grodno\\{[a-z0-9]+\\}", xor_bytes)
    if not m:
        print("flag pattern not found")
        return
    print(m.group(0).decode())


if __name__ == "__main__":
    main()

Let's wait a little and get the flag:
grodno{deadlyparkourkillerdarkbrawlstarsassasinstalkersniper1998rus}

New Year's Quantization

Think beyond pixels, sometimes the storage format of an image matters.

Now let's move on to my Magnum Opus.
The idea for this task came to me about six months ago when I came across an article about the JPEG format on Wikipedia.

First, I'll tell you what the task about, and then I'll move on to the solution itself.

The script hides the payload bitwise in the parity bit (LSB) of the quantized DCT coefficients of the luminance (Y) component of a JPEG. It:

  • Divides the image into 8x8 blocks,
  • performs an orthonormal DCT on each block,
  • divides the coefficients by the quantization table entries (rounds them) to obtain integer quantized coefficients,
  • changes the parity (±1) of the required AC coefficients so that it matches the payload bit,
  • restores (multiply by the quantization table → inverse DCT → level shift), and saves the image as a JPEG.

The hint says that the flag itself is located in the Y component.

import sys
from PIL import Image
import numpy as np
import math

def build_dct_matrix(N=8):
    C = np.zeros((N, N), dtype=np.float64)
    for k in range(N):
        for n in range(N):
            if k == 0:
                a = math.sqrt(1.0 / N)
            else:
                a = math.sqrt(2.0 / N)
            C[k, n] = a * math.cos(math.pi * (2*n + 1) * k / (2.0 * N))
    return C

def block_dct(block):
    return C8 @ block @ C8_T

def quality_scale_table(q):
    q = max(1, min(100, q))
    if q < 50:
        scale = 5000.0 / q
    else:
        scale = 200.0 - 2.0 * q
    qt = ((STD_QT * scale) + 50.0) // 100.0
    qt[qt < 1] = 1
    return qt

ZIGZAG = [
 0,  1,  5,  6, 14, 15, 27, 28,
 2,  4,  7, 13, 16, 26, 29, 42,
 3,  8, 12, 17, 25, 30, 41, 43,
 9, 11, 18, 24, 31, 40, 44, 53,
10, 19, 23, 32, 39, 45, 52, 54,
20, 22, 33, 38, 46, 51, 55, 60,
21, 34, 37, 47, 50, 56, 59, 61,
35, 36, 48, 49, 57, 58, 62, 63
]
ZIGZAG_RC = [(idx // 8, idx % 8) for idx in ZIGZAG]

STD_QT = np.array([
 [16,11,10,16,24,40,51,61],
 [12,12,14,19,26,58,60,55],
 [14,13,16,24,40,57,69,56],
 [14,17,22,29,51,87,80,62],
 [18,22,37,56,68,109,103,77],
 [24,35,55,64,81,104,113,92],
 [49,64,78,87,103,121,120,101],
 [72,92,95,98,112,100,103,99]
], dtype=np.float64)

C8 = build_dct_matrix(8)
C8_T = C8.T

def extract_bits_for_quality(stego_path, q):
    im = Image.open(stego_path).convert('YCbCr')
    y, cb, cr = im.split()
    y_arr = np.array(y, dtype=np.float64)
    h0, w0 = y_arr.shape
    qt = quality_scale_table(q)
    H = ((h0 + 7) // 8) * 8
    W = ((w0 + 7) // 8) * 8
    y_p = np.pad(y_arr, ((0, H - h0), (0, W - w0)), mode='edge')
    blocks_h = H // 8
    blocks_w = W // 8
    bits = []
    for br in range(blocks_h):
        for bc in range(blocks_w):
            r0 = br * 8
            c0 = bc * 8
            block = y_p[r0:r0+8, c0:c0+8] - 128.0
            D = block_dct(block)
            Q = np.rint(D / qt).astype(int)
            for zind in range(1,64):
                rr, cc = ZIGZAG_RC[zind]
                bits.append(int(Q[rr,cc]) & 1)
    return bits

def bits_to_bytes(bits):
    if len(bits) < 32:
        return None
    blen = 0
    for i in range(32):
        blen = (blen << 1) | bits[i]
    need = 32 + blen*8
    if need > len(bits):
        return None
    out = bytearray()
    for i in range(32, need):
        if (i-32) % 8 == 0:
            out.append(0)
        out[-1] = (out[-1] << 1) | bits[i]
    return bytes(out)

def try_all_qualities(stego_path, qmin=40, qmax=95):
    for q in range(qmin, qmax+1):
        try:
            bits = extract_bits_for_quality(stego_path, q)
            payload = bits_to_bytes(bits)
            if payload is None:
                continue
            if b'flag{' in payload or all(32 <= b < 127 for b in payload[:min(50,len(payload))]):
                print(f"[+] Possibly quality={q}, payload_len={len(payload)} bytes")
                print(payload[:200])
                open(f"recovered_q{q}.bin","wb").write(payload)
        except Exception as e:
            pass

if __name__ == "__main__":
    if len(sys.argv) < 2:
        print("Usage: try_bruteforce_quality.py stego.jpg")
        sys.exit(1)
    try_all_qualities(sys.argv[1], 40, 95)

The script isn't very pretty and it could be optimized and made better.

The decoder tries to extract hidden bits from the quantized DCT coefficients of the luminance (Y) channel, assuming that:

  • the data is hidden in the LSB (least significant bit) of the coefficients,
  • the standard JPEG quantization table is used,
  • the JPEG quality is unknown, so it is brute-forced,
  • the first 4 bytes (32 bits) are the payload length (big-endian).

This is how we get a flag: grodno{Qu@ntizat10n_1nfel1c1tq_m@tters}

NorthWind Secrets

While investigating the coordinator's Telegram account, we discovered that he is a member of a group with an unusual name: NYHEIST2026. A very strange base64 file was found in it. Decrypt the group's hidden plans.

After going to the group, we find a TXT file with 2.1 million characters in base64.

With the help of CyberChef, we find out that there's a hidden zip archive in this base64.

By writing a simple Python script, we will restore the archive itself and see that it is password-protected.

Lovley John the Ripper quickly cracks the password (654321) and we see 512 pieces of the image.

Using Chatagpt, let's write a script to restore the full image.

import os
import argparse
from PIL import Image

def parse_args():
    p = argparse.ArgumentParser(description="Rebuild image from 512 parts.")
    p.add_argument("parts_dir", help="Directory with part1..part512 images")
    p.add_argument("output", help="Output image path")
    p.add_argument("--cols", type=int, default=32)
    p.add_argument("--rows", type=int, default=16)
    p.add_argument("--prefix", default="part")
    p.add_argument("--ext", default="png")
    return p.parse_args()

def main():
    args = parse_args()

    cols = args.cols
    rows = args.rows
    prefix = args.prefix
    ext = args.ext
    parts_dir = args.parts_dir

    total = cols * rows

    first_path = os.path.join(parts_dir, f"{prefix}1.{ext}")
    if not os.path.exists(first_path):
        raise FileNotFoundError(f"Missing {first_path}")

    first = Image.open(first_path)
    tile_w, tile_h = first.size
    mode = first.mode

    out = Image.new(mode, (tile_w * cols, tile_h * rows))

    index = 1
    for r in range(rows):
        for c in range(cols):
            part_name = f"{prefix}{index}.{ext}"
            part_path = os.path.join(parts_dir, part_name)

            if not os.path.exists(part_path):
                raise FileNotFoundError(f"Missing {part_path}")

            tile = Image.open(part_path)
            out.paste(tile, (c * tile_w, r * tile_h))
            index += 1

    out.save(args.output)
    print(f"[+] Rebuilt image saved as {args.output}")

if __name__ == "__main__":
    main()

Afterwards, we open the resulting image:

Flag: grodno{1_knew_th@t_th1s_w0uldn't_w0rk}