← Back to Writeups
CUSTOMEasyForensics

Морзянка на Arduino (Morse code on Arduino)

XESXOR9/2/20266 min read
#forensics#custom#easy#brightness_thresholding#morse_decoding#run_length_encoding#temporal_variance_source_local#video_frame_extraction#linux

Морзянка на Arduino (Morse code on Arduino)

Platform: bug-makers | Category: Forensics | Type: Challenge | Difficulty: Easy | OS: Linux | Author: XESXOR | Date: 2025-10-04 | Status: Solved Techniques: brightness_thresholding, morse_decoding, run_length_encoding, temporal_variance_source_localization, video_frame_extraction

Summary

Task: a video of an Arduino onboard LED (pin 13) blinking the flag in Morse code, with the transmitter .ino giving the exact timing scheme. Solution: extract frames with ffmpeg, locate the blinking LED via maximum temporal pixel variance, threshold the brightness signal, run-length-encode ON/OFF durations into dots/dashes/gaps, and decode against the Morse map (including custom brace/underscore symbols).

Морзянка на Arduino (Morse code on Arduino) — bug-makers

Description

Морзянка на Arduino. Светодиод передает флаг в формате азбуки Морзе. Нужно считать его послание – это флаг.

Translation: an LED transmits the flag as Morse code. Read its message — that is the flag.

Provided materials:

  • A Wokwi Arduino emulator carrying a TEST flag: https://wokwi.com/projects/441825314085368833
  • The transmitter source code morze.ino.
  • A video morze-led.mov (73 s, 60 fps, 1212×1146) showing the onboard LED (pin 13) blinking the real flag in Morse. ‍​‌‌​​​​‌​‌‌​​‌‌​​‌‌​​‌‌​​‌‌​​‌​​​​‌‌​​​​​‌‌​​‌‌​​‌‌​​‌​‌​‌‌​​‌​‌‍

Goal: read the Morse message from the blinking LED in the video to recover the flag.

Analysis

The morze.ino source defines the exact Morse timing scheme and pin, which removes all guesswork about decoding parameters:

  • DOT_DURATION = 200 ms — LED ON for a dot
  • DASH_DURATION = 600 ms — LED ON for a dash (3× a dot)
  • SYMBOL_PAUSE = 200 ms — LED OFF between dots/dashes within one letter
  • LETTER_PAUSE = 600 ms — LED OFF between letters
  • WORD_PAUSE = 1000 ms — LED OFF between words
  • LED_PIN = 13 — the standard onboard LED
  • Standard Morse map for A–Z and 0–9, plus custom symbols needed for the flag format:
    • _ = ..--.-
    • { = -.--.
    • } = -.--.-
  • The message inside setup() is BUGCTF{TEST_FLAG} — a decoy. The actual flag is only present in the morze-led.mov recording. ‍​‌‌​​​​‌​‌‌​​‌‌​​‌‌​​‌‌​​‌‌​​‌​​​​‌‌​​​​​‌‌​​‌‌​​‌‌​​‌​‌​‌‌​​‌​‌‍

So the task reduces to: turn the LED's brightness over time into ON/OFF runs, classify them as dots/dashes and gaps, and map them through the Morse table.

Two practical complications drove the approach:

  1. Where is the LED? The frame is large (1212×1146) and mostly static. The LED is the only thing that changes between frames. The robust way to find it is temporal variance: stack all frames and pick the pixel with maximum variance over time. Trying to find the LED by absolute brightness failed — the background was saturated/uniform.
  2. Timings were scaled, but relative structure held. The captured durations did not match the source constants exactly (recorded dot ≈ 300 ms, dash ≈ 700 ms, intra-letter gap ≈ 100 ms, letter gap ≈ 300 ms). But the distribution was cleanly bimodal and separable, so relative thresholds (dot vs dash, and three gap classes) decoded perfectly without needing the absolute source values. ‍​‌‌​​​​‌​‌‌​​‌‌​​‌‌​​‌‌​​‌‌​​‌​​​​‌‌​​​​​‌‌​​‌‌​​‌‌​​‌​‌​‌‌​​‌​‌‍

Solution

Step 1 — inspect the video:

bash
ffprobe morze-led.mov          # 73 s, 60 fps, 1212x1146

Step 2 — extract frames at 30 fps, downscaled (the LED is large enough to survive heavy downscaling, and smaller frames make the numpy stack cheap):

bash
mkdir -p frames2
ffmpeg -i morze-led.mov -vf "scale=300:284" -r 30 frames2/f%05d.png

Step 3 — locate the LED by temporal variance, build the brightness signal, threshold, run-length-encode, and decode:

python
#!/usr/bin/env python3
import glob
import numpy as np
from PIL import Image
‍​‌‌​​​​‌​‌‌​​‌‌​​‌‌​​‌‌​​‌‌​​‌​​​​‌‌​​​​​‌‌​​‌‌​​‌‌​​‌​‌​‌‌​​‌​‌‍

FPS = 30.0
MS_PER_FRAME = 1000.0 / FPS

# --- Load all frames as a grayscale stack: shape (T, H, W) ---
paths = sorted(glob.glob("frames2/f*.png"))
stack = np.stack([
    np.asarray(Image.open(p).convert("L"), dtype=np.float32)
    for p in paths
])  # (T, H, W)

# --- Locate the blinking LED: pixel with MAX temporal variance ---
var = stack.var(axis=0)                       # (H, W)
y, x = np.unravel_index(int(var.argmax()), var.shape)
print(f"LED located at y={y}, x={x}")

# --- Brightness signal: average a small region around the LED ---
r = 3
region = stack[:, max(0, y - r):y + r + 1, max(0, x - r):x + r + 1]
signal = region.mean(axis=(1, 2))             # (T,)
‍​‌‌​​​​‌​‌‌​​‌‌​​‌‌​​‌‌​​‌‌​​‌​​​​‌‌​​​​​‌‌​​‌‌​​‌‌​​‌​‌​‌‌​​‌​‌‍

# --- Threshold at midpoint of (min+max) to get ON/OFF ---
lo, hi = signal.min(), signal.max()
thr = (lo + hi) / 2.0
on = signal >= thr

# --- Run-length encode into (state, duration_ms) ---
runs = []
cur, cnt = on[0], 1
for v in on[1:]:
    if v == cur:
        cnt += 1
    else:
        runs.append((bool(cur), cnt * MS_PER_FRAME))
        cur, cnt = v, 1
runs.append((bool(cur), cnt * MS_PER_FRAME))

# Drop leading/trailing OFF padding
while runs and not runs[0][0]:
    runs.pop(0)
while runs and not runs[-1][0]:
    runs.pop()
‍​‌‌​​​​‌​‌‌​​‌‌​​‌‌​​‌‌​​‌‌​​‌​​​​‌‌​​​​​‌‌​​‌‌​​‌‌​​‌​‌​‌‌​​‌​‌‍

# --- Classify runs into Morse tokens ---
# ON  : <500ms -> dot, >=500ms -> dash
# OFF : <200ms -> intra-letter, 200-800ms -> letter gap, >800ms -> word gap
ON_DOT_MAX   = 500
OFF_LETTER   = 200
OFF_WORD     = 800

letters = []          # list of words, each word is list of morse strings
cur_word = []
cur_sym = ""
for state, dur in runs:
    if state:  # ON
        cur_sym += "." if dur < ON_DOT_MAX else "-"
    else:      # OFF
        if dur < OFF_LETTER:
            pass  # gap within a letter
        elif dur < OFF_WORD:
            if cur_sym:
                cur_word.append(cur_sym)
                cur_sym = ""
        else:
            if cur_sym:
                cur_word.append(cur_sym)
                cur_sym = ""
            if cur_word:
                letters.append(cur_word)
                cur_word = []
if cur_sym:
    cur_word.append(cur_sym)
if cur_word:
    letters.append(cur_word)
‍​‌‌​​​​‌​‌‌​​‌‌​​‌‌​​‌‌​​‌‌​​‌​​​​‌‌​​​​​‌‌​​‌‌​​‌‌​​‌​‌​‌‌​​‌​‌‍

# --- Morse map (standard + custom flag symbols from morze.ino) ---
MORSE = {
    ".-": "A", "-...": "B", "-.-.": "C", "-..": "D", ".": "E", "..-.": "F",
    "--.": "G", "....": "H", "..": "I", ".---": "J", "-.-": "K", ".-..": "L",
    "--": "M", "-.": "N", "---": "O", ".--.": "P", "--.-": "Q", ".-.": "R",
    "...": "S", "-": "T", "..-": "U", "...-": "V", ".--": "W", "-..-": "X",
    "-.--": "Y", "--..": "Z",
    "-----": "0", ".----": "1", "..---": "2", "...--": "3", "....-": "4",
    ".....": "5", "-....": "6", "--...": "7", "---..": "8", "----.": "9",
    "..--.-": "_", "-.--.": "{", "-.--.-": "}",
}
‍​‌‌​​​​‌​‌‌​​‌‌​​‌‌​​‌‌​​‌‌​​‌​​​​‌‌​​​​​‌‌​​‌‌​​‌‌​​‌​‌​‌‌​​‌​‌‍

# Decode (single word here, but join words just in case)
out = " ".join(
    "".join(MORSE.get(sym, "?") for sym in word)
    for word in letters
)
print(out)

Step 4 — running the decoder prints the flag.

The key tricks:

  • Locate the signal source by temporal variance, not absolute brightness — the LED is the one pixel that varies over the whole clip.
  • Use relative thresholds for dot/dash and the three gap classes. Even though absolute timings were scaled relative to the .ino constants, the durations were cleanly bimodal and separable, so a midpoint/relative classification decoded reliably.
  • Add the custom {, }, _ Morse codes from the source, otherwise the flag-format characters decode as ?. ‍​‌‌​​​​‌​‌‌​​‌‌​​‌‌​​‌‌​​‌‌​​‌​​​​‌‌​​​​​‌‌​​‌‌​​‌‌​​‌​‌​‌‌​​‌​‌‍