Este blog es solo para fines educativos y de análisis técnico en CTFs. No promueve actividades maliciosas ni el uso indebido de herramientas descritas.
Entrada

PAL de Recuerdos - NNCTF2025

Reto basado en la decodificación de una señal PAL estándar para obtener una grabación antigua.

PAL de Recuerdos - NNCTF2025

Nombre del reto: PAL de Recuerdos

Autor del reto: Kesero (Creado por mí)

Dificultad: Difícil

Enunciado

1
2
3
4
5
6
7
8
9
10
11
12
13
14
Entre las cosas que rescatamos el pasado fin de semana mi tío y yo, encontramos una caja llena de cintas, disquetes y papeles de su adolescencia. Rebuscando entre ellos, apareció una grabación con una etiqueta escrita a rotulador que decía:
"Navaja Negra 2013 – PAL".

Me miró con una mezcla de nostalgia y me dijo:

"Esto es de una charla que emitieron hace años... Dicen que tenía un mensaje oculto, algo que solo unos pocos lograron ver. Yo lo intenté, pero nunca supe cómo hacerlo.
Tú, con tus cacharros... ¿crees que podrías echarle un vistazo?”

La señal parecía proceder de una antigua transmisión de vídeo en blanco y negro, con una resolución de 768 píxeles por línea y 576i, siguiendo el estándar PAL.
Gracias a mis herramientas y algo de paciencia, logré digitalizarla y guardarla como un archivo .vcd, donde pude aislar la señal de sincronización de la carga útil.

Mi tío, sin embargo, sigue convencido de que hay algo importante ahí dentro. Lo noto en sus ojos cada vez que lo recuerda.

Yo... ya no sé si es solo nostalgia, o si realmente hay algo escondido que merece ser visto.

Archivos

1
video_capture.vcd
$timescale 1ns $end
$scope module logic $end
$var wire 1 ! D0 $end
$var wire 1 " D1 $end
$upscope $end
$enddefinitions $end
#0
0!
0"
#0
1!
#4000
0!
#4000
0"
#196000
1!
#200000
0!
#200000
0"
(...)

Archivos utilizados aquí.

Solución

El archivo video_capture.vcd contiene una captura digitalizada de una señal de vídeo analógica PAL (768x576i, 50 Hz) que debemos de transformar en contenido visual.

El sistema PAL utiliza un esquema entrelazado: primero se transmiten las líneas impares y luego las pares, mostrando 50 “medios fotogramas” por segundo. Dos de estos medios fotogramas forman un cuadro completo, lo que resulta en 25 cuadros por segundo.

pal entrelazado

Análisis de video_capture.vcd

El archivo video_capture.vcd contiene eventos digitales en formato VCD (Value Change Dump), que se pueden interpretar como una señal digitalizada de un vídeo compuesto. Del archivo podemos extraer la siguiente información:

  • D0 (!) contiene la información referente a la sincronización vertical/horizontal.
  • D1 (") contiene los datos de luminancia de los píxeles. Los cambios de D1 representan los niveles de gris de cada píxel.
  • Los timestamps de la captura se expresan en nanosegundos.
  • Los pulsos de sincronización (!) indican el inicio de una línea de vídeo o de un nuevo frame.
  • Cada bit de la señal de sincronización (sync_pulse_duration) dura 4000 ns.

Decodificación

Para decodificar la señal y reconstruir los frames pertenecientes al vídeo codificado en video_capture.vcd, primero se realiza una lectura línea por línea, extrayendo el valor de timestamp y las señales D0 y D1 junto con sus valores correspondientes.

Posteriormente, tenemos que expresar una lógica de detección de sincronización. Cuando D0 toma el valor 1, se interpreta como el inicio de una línea de vídeo. Además, tenemos que establecer un umbral de tiempo para asumir el comienzo de un nuevo frame.

Por último, tenemos que decodificar la información extraída en cada frame basándonos en los pulsos de sincronización, reconstruyendo las líneas de vídeo en arrays de píxeles con su correspondiente escala de grises. Al agrupar estas líneas se obtienen los frames completos y por último los guardaremos como imagen.

El script que realiza el proceso descrito es el siguiente:

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
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
import numpy as np
from PIL import Image
import os

def read_vcd(filename):
    """Lee el archivo VCD y devuelve una lista de eventos (timestamp, signal, value)."""
    events = []
    timestamp = 0
    with open(filename) as f:
        for line in f:
            line = line.strip()
            if line.startswith("#"):
                timestamp = int(line[1:])
            elif line.startswith(("0", "1")):
                value = int(line[0])
                signal = line[1]
                events.append((timestamp, signal, value))
    return events

def extract_line(events, start_idx, pixels_per_line, bit_duration):
    """Extrae los bits de una línea de vídeo a partir del índice de inicio."""
    line_start_time = events[start_idx][0]
    line_bits = np.zeros(pixels_per_line, dtype=np.uint8)

    # Valor inicial de D1
    current_value = 0
    j = start_idx - 1
    while j >= 0:
        if events[j][1] == '"':
            current_value = events[j][2]
            break
        j -= 1

    # Cambios futuros de D1
    next_changes = [(t, v) for t, s, v in events[start_idx:] if s == '"']
    next_idx = 0

    for p in range(pixels_per_line):
        sample_time = line_start_time + p * bit_duration
        while next_idx < len(next_changes) and next_changes[next_idx][0] <= sample_time:
            current_value = next_changes[next_idx][1]
            next_idx += 1
        line_bits[p] = current_value

    return line_bits

def decode_frames(events, pixels_per_line, active_lines, bit_duration, frame_duration_threshold):
    """Decodifica todos los frames de la lista eventos."""
    frames = []
    current_frame_lines = []
    last_sync_time = 0
    i = 0
    frame_count = 0

    while i < len(events):

        # Buscar el inicio de sincronización
        while i < len(events) and not (events[i][1] == "!" and events[i][2] == 1):
            i += 1
        if i >= len(events):
            break
        sync_start = events[i][0]

        # Nuevo frame si ha pasado suficiente tiempo
        if sync_start - last_sync_time > frame_duration_threshold and current_frame_lines:
            frame_array = np.array(current_frame_lines[:active_lines], dtype=np.uint8) * 255
            frames.append(Image.fromarray(frame_array, mode="L"))
            current_frame_lines = []
            frame_count += 1
            print(f"[+] Frame {frame_count} recuperado")

        last_sync_time = sync_start
        i += 1

        # Esperar fin del pulso de sincronización
        while i < len(events) and not (events[i][1] == "!" and events[i][2] == 0):
            i += 1
        if i >= len(events):
            break

        # Extraer línea de video
        line_bits = extract_line(events, i, pixels_per_line, bit_duration)
        current_frame_lines.append(line_bits)
        i += 1

    # Guardar último frame
    if current_frame_lines:
        frame_array = np.array(current_frame_lines[:active_lines], dtype=np.uint8) * 255
        frames.append(Image.fromarray(frame_array, mode="L"))
        frame_count += 1
        print(f"[+] Frame {frame_count} recuperado")

    return frames

def save_frames(frames, folder="frames", prefix="frame"):
    """Guarda los frames como imágenes en formato png"""
    os.makedirs(folder, exist_ok=True)
    
    for i, frame in enumerate(frames):
        filename = os.path.join(folder, f"{prefix}_{i:04d}.png")
        frame.save(filename)
    
    print(f"\n[!] Guardados {len(frames)} frames en '{folder}/'")

def main():

        # Configuración
        pixels_per_line = 768
        active_lines = 576
        bit_duration = 250  # ns
        sync_pulse_duration = 4000  # ns
        frame_duration_threshold = 1_000_000  # ns

        events = read_vcd("video_capture.vcd")
        frames = decode_frames(events, pixels_per_line, active_lines, bit_duration, frame_duration_threshold)
        if frames:
            save_frames(frames)
        else:
            print("No se detectaron frames.")

if __name__ == "__main__":
    main()
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
[~]─$ python decoder.py

[+] Frame 1 recuperado
[+] Frame 2 recuperado
[+] Frame 3 recuperado
[+] Frame 4 recuperado
[+] Frame 5 recuperado
[+] Frame 6 recuperado
[+] Frame 7 recuperado
[+] Frame 8 recuperado
[+] Frame 9 recuperado
[+] Frame 10 recuperado
[+] Frame 11 recuperado
[+] Frame 12 recuperado
[+] Frame 13 recuperado
[+] Frame 14 recuperado
[+] Frame 15 recuperado
[+] Frame 16 recuperado

[!] Guardados 17 frames en la carpeta frames

Los frames más relevantes de la decodificación anterior son los siguientes:

frame1 frame2 frame3 frame4 frame5 frame6 frame7

Flag

nnctf{R3cUerDos_qu3_nUnc4_s3_0lv1d4n}

Esta entrada está licenciada bajo CC BY 4.0 por el autor.