Sleepy CPU
The length of each sleep interval is the flag byte - a power side-channel you can read with a threshold.
NNS{pow3r_4n4lys15_c4n_rev3al_what_th3_cpu_i5_w0rk1ng_on} Challenge
We are given:
- sleepy_cpu.jls: a Joulescope current/power/voltage measurement capture of a real microcontroller board.
- zephyrapp/: the Zephyr RTOS firmware source that was running on the board.
main.c:
#include "flag.h"
#include <zephyr/kernel.h>
int main()
{
for (char* c = flag; *c; c++)
{
for (int i = 0; i < 100000; i++)
{
__asm volatile ("nop");
}
k_sleep(K_MSEC(*c));
}
for (int i = 0; i < 100000; i++)
{
__asm volatile ("nop");
}
}
For every character of the flag, the firmware busy-loops (100000 NOPs - CPU
fully active, higher current draw) and then calls k_sleep(K_MSEC(*c)) - the
CPU goes idle for exactly c milliseconds, where c is the numeric
(ASCII) value of the flag character itself. This is a classic simple power
side-channel: the duration of each low-power (idle/sleep) interval leaks
the flag byte directly, no waveform-shape analysis needed.
Approach
-
The .jls format is Joulescope’s binary capture format. Installed the official
pyjlspython package (pip install pyjls numpy) to read it (Python and pip were not present on the box, so Python 3.12 was installed via winget first). -
Opened the file and enumerated signals:
with pyjls.Reader("sleepy_cpu.jls") as r:
for sid, sig in r.signals.items():
print(sid, sig)
Signal 2 is “current” (A), sample_rate=50000 Hz, length=630278 samples (~12.6 s capture).
- Extracted the full current waveform:
data = r.fsr(2, 0, sig.length) # numpy float32 array of amperes
-
Classified each sample as ACTIVE (current > 0.0025 A, i.e. the NOP busy-loop) or idle (sleeping/low current) via a simple threshold found from the histogram of current values (clear bimodal split around ~0.0009 A idle vs ~0.003-0.004 A active).
-
Ran a run-length encoding over the boolean active/idle array, and de-glitched it by merging any run shorter than 5 samples (0.1 ms) into the previous run (these are single-sample threshold-crossing artifacts inside the busy loop, not real state changes).
-
After de-glitching, the trace decomposes cleanly into an initial boot/ idle period, then 57 repeating (ACTIVE ~6.3 ms NOP-loop, idle N ms) pairs, then a final long idle (program finished). The idle duration of each pair, rounded to the nearest millisecond, is exactly the ASCII code of one flag character:
idle_runs = [ms for state, ms in runs if state == idle]
chars = idle_runs[2:-1] # drop the two boot idles and the trailing idle
flag = "".join(chr(round(c)) for c in chars)
This produced:
NNS{pow3r_4n4lys15_c4n_rev3al_what_th3_cpu_i5_w0rk1ng_on}
which is valid UTF-8/ASCII and reads as a sensible sentence, confirming the decode was correct.
Flag
NNS{pow3r_4n4lys15_c4n_rev3al_what_th3_cpu_i5_w0rk1ng_on}