Embedded encryptor

Locate the encryption in the power trace, align per-block, recover the key with CPA (last two bytes handled separately)

2026.09.30 NNS CTF 2026 133 pts Misc
FLAG NNS{1e4k_by_pwr}

Author: simen Points: 133 Flag: NNS{1e4k_by_pwr}

Description

Look, my STM32 can do cryptography. Here is a power trace so you can really see the difference!

Two files:

  • misc_Embedded-encryptor.tar.gz (42 KB) — Zephyr firmware source + a captured serial log (output.txt)
  • embedded_encryptor.7z (182 MB, decompresses to a 327 MB .jls power trace, same Joulescope format as SLEEPY CPU)

Source

handout/zephyrapp/src/main.c:

const uint8_t flag[AES_BLOCKLEN] = "NNS{faake_flaag}";
const uint8_t iv[AES_BLOCKLEN] = { 0x10,0x9a,0x41,0xbc,0xa7,0x71,0xbd,0x4b,
                                    0xe3,0x52,0x27,0x71,0x2f,0xd8,0x63,0x98 };
char havamal[] = "Gáttir allar, ..."; // ~10.4 KB of the Old Norse poem Hávamál

int main(void) {
    k_sleep(K_MSEC(1000));
    struct AES_ctx ctx;
    AES_init_ctx_iv(&ctx, flag, iv);
    AES_CBC_encrypt_buffer(&ctx, havamal, num_blocks * AES_BLOCKLEN);
    k_sleep(K_MSEC(1000));
    // ... LOG_HEXDUMP_INF each ciphertext block
}

The firmware (a Cortex-M3 STM32F103, clocked deliberately slow at 2 MHz via the devicetree overlay) uses the flag itself as the AES-128 key, with a fixed IV, and CBC-encrypts a long known plaintext (the Hávamál) using tiny-AES-c (plain table-based software AES, no HW crypto peripheral). The resulting ciphertext for every block is also logged in output.txt. This is the textbook setup for Correlation Power Analysis (CPA): known plaintext, known ciphertext, unknown fixed key, many traces of the same key being used.

sizeof(havamal) (including the NUL terminator) is 10441 bytes → num_blocks = 10441/16 = 652 blocks, and output.txt confirms exactly 652 logged blocks (block 0 .. block 651).

Locating the encryption in the power trace

The .jls trace is 1 MHz-sampled current data, ~26.3 s long. Downsampling to 1 ms bins and looking at the mean trend shows the expected shape from main():

  • 0–3.5s: idle baseline
  • 3.5–4.6s: the first k_sleep(1000ms) (lower current, “sleep” — with a regulator-settling decay tail on wake)
  • 4.6–13.4s: the AES_CBC_encrypt_buffer loop — visibly periodic, medium/variable current (652 blocks)
  • 13.4–14.4s: the second k_sleep(1000ms)
  • 14.4–23.4s: LOG_HEXDUMP_INF printing all 652 blocks over UART (a different, much more uniform power signature — this is I/O, not compute, and irrelevant to the attack)

Extracting per-block traces

  1. Autocorrelation (FFT-based, with parabolic interpolation for sub-sample precision) on the AES-loop region gives a very stable block period of ≈13,378.82 samples (≈13.38 ms/block @ 1 MHz — consistent with 8.65e6 samples / 652 blocks).
  2. A coarse-to-fine phase search (stacking N candidate block windows and maximizing the variance of their average — misalignment blurs the average, correct alignment sharpens it) locates the fractional phase within one period.
  3. Per-window variance was used to find where the real periodic AES activity starts (windows before it are near-zero variance / flat baseline, windows from block 0 onward jump to a stable high variance) — this fixes which absolute sample is “block 0”.
  4. Each of the 652 nominal block windows still has a few samples of jitter (data-dependent branches in tiny-AES-c’s xtime() GF(2^8) multiply cause small timing variation), so each block window is individually re-aligned via normalized cross-correlation against a template (average of the first 30 blocks), searching a small (±400 sample) window. One outlier trace (a bad/edge window) was dropped; the rest needed only a few samples of correction.

CPA attack

For CBC, the AES core operates on state = plaintext_block XOR previous_ciphertext_block (with previous_ciphertext_block = IV for block 0). This gives, per block, the exact 16-byte input to the first AddRoundKey + SubBytes — no key knowledge needed, since both plaintext and every ciphertext block are known.

Standard first-round CPA, independently per key byte k:

  • Hypothesis: H[i, g] = HammingWeight(SBox[state[i] XOR g]) for every trace i and every guess g in 0..255.
  • Correlate H[:, g] against the (aligned) power trace at every time sample, across all 652 traces (Pearson correlation).
  • Best-correlating guess per byte, restricted to the small consistent time window (~10,350–10,650 samples into the block, where all 16 bytes’ peaks cluster — the SubBytes loop) to avoid picking up noise elsewhere in the ~13k-sample block.

This recovered 14 of 16 bytes with a clearly-dominant guess (NNS?1?4k_by_pwr}-shaped, with ? at the two weakest-correlation byte positions — bytes 3 and 6, where the correct guess vs. the runner-up were too close to call from correlation alone).

Closing the last 2 bytes

Rather than fight for more SNR, the ambiguous bytes are cheap to resolve directly: take the top ~8 CPA candidates for byte 3 and byte 6, keep every other byte fixed at its confident CPA guess, and brute-force check each combination by actually AES-CBC-encrypting the known plaintext and comparing to the known ciphertext (a handful of AES calls, trivial cost):

from Crypto.Cipher import AES
base = bytearray(b"NNS\x121e4k_by_pwr}")
for b3 in cand3:
    for b6 in cand6:
        key = bytes(base[:3] + bytes([b3]) + base[4:6] + bytes([b6]) + base[7:])
        if AES.new(key, AES.MODE_CBC, iv=iv).encrypt(pt_block0) == expected_block0:
            print(key)   # NNS{1e4k_by_pwr}

key = b"NNS{1e4k_by_pwr}" re-encrypts all 652 blocks to exactly the ciphertext in output.txt — fully confirmed.

Flag: NNS{1e4k_by_pwr} (leetspeak “leak” — the flag literally leaks through the power side-channel).

Takeaways

  • Using the flag directly as a fixed AES key, encrypted many times over a known plaintext with a captured power trace, is a direct invitation for classical CPA — no need to attack the embedded system itself.
  • Software (table-lookup) AES on a slow, simple MCU core (STM32F103, Cortex-M3, no cache/pipeline hazards to speak of) is about as side-channel-friendly a target as exists; 652 traces of the same key is plenty for CPA even with a fairly noisy Hamming-weight model.
  • When CPA leaves a couple of bytes ambiguous, don’t try to force more signal out of the traces — if you have any known-plaintext/known-ciphertext pair, brute-forcing the remaining uncertain bytes against a real encryption oracle is trivial and removes all doubt.
#hardware