Embedded encryptor
Locate the encryption in the power trace, align per-block, recover the key with CPA (last two bytes handled separately)
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.jlspower 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 baseline3.5–4.6s: the firstk_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 secondk_sleep(1000ms)14.4–23.4s:LOG_HEXDUMP_INFprinting 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
- 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).
- 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.
- 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”.
- 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 traceiand every guessgin0..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
SubBytesloop) 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.