--:--
ping
download
upload
← back to blog

· ctf · forensics · signals · side-channels · writeups · author · 17 min read

Leaking Data Through 5 Volts: The USB Power Line as a Covert Channel

Ever noticed how many signal forensics challenges in CTFs an AI can break in seconds? I made one that held out against AI for almost 7 hours.

It’s called TX, an old forensics challenge of mine from GCUP 2.0 CTF, organized by Securinets in collaboration with the Institut français de Tunisie (IFT). It was finally solved with Codex. The flag leaks through the one part of a USB port nobody watches: the power line.

Every USB port gives a device two things: a data connection and 5 volts. Security people spend almost all their attention on the first one. We block mass storage, allowlist keyboards, and hand out “charge-only” cables and data blockers. The power pins get ignored, because power isn’t data.

It can be. The voltage on a power line depends on how much current every device on it draws, and anything that shares the line can measure that voltage. So a device that controls its current can send, and a device that watches its supply voltage can receive, with no data connection between them.

This post starts with that phenomenon: why power lines leak, what published research has done with it, and why the usual USB defenses miss it. Then it goes into how TX was built and how to solve it.


Part 1: The phenomenon

Power has been leaking secrets for a long time

Using power as a leak isn’t new. In 1999, Kocher, Jaffe and Jun published Differential Power Analysis: record a smart card’s power consumption over many runs of a cryptographic algorithm, and statistics on those traces recover its secret key. The chip isn’t doing anything wrong. Transistors draw current when they switch, what switches depends on the data, so the data ends up in the current.

Two terms help here:

TX is the second kind: a malicious device that modulates the power line on purpose.

Why a USB port’s voltage moves

A USB port isn’t a perfect 5 V source. Between the regulator and your device there is always some resistance: the regulator’s own output impedance, the current-limit switch or polyfuse that protects the port, PCB traces, connector contacts, the cable. When the current changes, the voltage changes with it, by Ohm’s law: ΔV = ΔI · R.

The numbers are small but measurable. A few hundred milliohms of resistance and a 100 mA change in current gives tens of millivolts. Fast changes also hit the regulator, which can’t react instantly. Its output capacitors cover the gap, the voltage dips, then the control loop pulls it back, often overshooting a bit. So a burst of current shows up on the rail as a short, smooth pulse, not a clean step.

In TX’s story, the extra electronics inside the keyboard are a capacitor bank. It charges slowly, then dumps charge in short bursts back onto VBUS, so the voltage briefly goes up. A normal keyboard never pushes current back into the host’s VBUS, and that’s part of what makes this one malicious.

A shared rail is a party line

Every device on the same hub hangs off the same VBUS node. The resistance they share (the hub’s regulator, its switch, its traces) is what couples them. When one device changes its current, the voltage at that shared node moves, and every other device on the node can measure it.

shared rail diagram

One rail, many listeners: the transmitter and the listener only share power, never data

The listener doesn’t need a data connection to the transmitter, a driver, or any permission on the host. It needs an ADC on its own power input.

This has been shown in published research:

TX is the same idea at USB scale: a covert transmitter on VBUS instead of a PC on the mains.

Why the usual USB defenses don’t see it

What does help: physical control over what gets plugged into sensitive machines, not sharing a power rail between trusted and untrusted devices, and, if a device looks suspicious, looking at VBUS with an oscilloscope instead of a USB meter.

What a real power rail looks like

A clean 5 V line doesn’t exist. On a real rail you typically find:

At TX’s time scale, two of these matter: 50 Hz hum on both VBUS and GND, and random noise. The pulses are about the same size as the noise, and the whole challenge comes from that.


Part 2: The challenge

The scenario

A suspicious USB device was found plugged into a secure workstation. Externally it looked like a standard keyboard, but it contained extra electronics including a large capacitor bank. Your task: recover the data that was exfiltrated via a clever side-channel.

Players got two files:

Designing the transmitter

The signal has three layers, from the voltage up to the flag.

Layer 1: pulse-position modulation (PPM). Time is split into 10 ms slots, and every slot gets exactly one pulse. The information is where the pulse sits inside the slot:

PPM symbols

One symbol per 10 ms slot. The pulse is a Ricker wavelet (τ = 1 ms, 30 mV)

The pulse shape is a Ricker wavelet (the “Mexican hat”): a smooth bump with a small undershoot on each side and zero net area. It’s a standard model pulse in seismology, and it’s close to what a short burst looks like on a rail where decoupling capacitors smooth the edges and a regulator pulls the voltage back.

def ricker_pulse(t, t_centre):
    dt = (t - t_centre) / PULSE_TAU          # PULSE_TAU = 1 ms
    return PULSE_DROP * (1 - dt*dt) * math.exp(-dt*dt / 2)   # PULSE_DROP = 30 mV

PPM suits a covert transmitter well:

Layer 2: Manchester. Each data bit becomes two PPM symbols:

bit symbols
0 1 0
1 0 1

Manchester guarantees a transition in every bit, and it gives a free sanity check: a pair like 00 or 11 means a symbol was read wrong.

Layer 3: the frame.

[ 64-bit preamble 1010… ][ sync 0xEB 0x90 ][ flag bytes ][ CRC-8/MAXIM ]

The preamble gives a clean alternating pattern to lock onto, 0xEB90 is a classic telemetry sync word, and the CRC tells you whether your decode is actually right.

That’s 64 + 16 + 39·8 + 8 = 400 bits, so 800 symbols, so 8 seconds of transmission in a 10-second capture.

The rabbit holes (on purpose)

Two of the three data sources are there to waste your time.

The pcap. It’s a HID keyboard capture. Decode the key reports and you get:

the quick brown fox jumps over the lazy dog
all your base are belong to us

No flag. The keyboard really is a keyboard. There’s one useful detail: the host polls it every 10 ms, the same length as a PPM slot.

The digital channels (Ch0/Ch1). They’re D+ and D−, carrying packets that look like USB: SYNC, SOF, and an IN / DATA / ACK every 16 frames with an all-zero payload. Nothing to find there. Also worth noticing: 1 MS/s is far too slow to properly capture full-speed USB (12 Mb/s), so these lines were never the place to look.

The data is in the analog channels.


Part 3: Solving it

Opening the capture

A .sal file is a ZIP. Rename it and you get meta.json, digital-0.bin, digital-1.bin, analog-2.bin and analog-3.bin. Analog samples are int16, converted to volts using the ranges in meta.json (fullScaleVoltageRanges). You can also open it in Logic 2 directly.

Finding the signal

Ch2 sits around 5.00 V, so it’s VBUS. Ch3 sits around 0 V, so it’s GND.

raw analog channels

Ch2 and Ch3 over the whole capture, then VBUS zoomed to 100 ms

Over the full 10 s, both channels look like noise. Notice that the VBUS band gets thinner after the 8 s mark, where the transmission ends. Zoomed in, VBUS has a slow wave (the 50 Hz hum), random noise, and short spikes at a regular rhythm, about 30 mV tall on a 5 V rail.

Two more clues:

Almost right isn’t a flag

A natural first attempt: for each 10 ms slot, find the most extreme sample and check which half of the slot it’s in. The challenge talks about “fluctuations”, and power problems are usually sags, so it’s tempting to reach for argmin:

def window_min_decode(signal):
    syms = []
    for i in range(len(signal) // SYM_N):
        win = signal[i * SYM_N: (i + 1) * SYM_N]
        syms.append(0 if np.argmin(win) < SYM_N // 2 else 1)
    return syms

It gets 136 of the 800 symbols wrong. The pulses point up, so the deepest point in each slot is one of the Ricker’s side lobes, which are only about 13 mV deep, and the noise wins often.

Once you notice that and switch to argmax, it looks solved: only 14 of 800 symbols wrong (98% right). Here is what that decodes to:

argmax, raw:      Securinets{u3b_p0w3r_l1n3\x1f3xf1ltr4u10~}      CRC ✗
argmax, cleaned:  Sec\xf5rinets{usb_p0w1r_l1n3_3xvqltr4t10n}      CRC ✗

Readable, and still wrong. You could probably guess the flag by combining the two, but a guess is still a guess, and the CRC says it’s wrong.

offset histogram

Where each method places the pulse inside its slot, over the 800 slots that carry data. The dotted line is the 4.5 ms decision boundary

The histograms show what’s going on:

The underlying issue is that picking one sample is a weak decision. At 10 kS/s the pulse’s main lobe is about 20 samples wide, and a single-sample decision ignores 19 of them.

The fix: clean up, then correlate

signal chain

The same 100 ms window at each stage of processing

Step 1: common-mode rejection with GND. The hum is on both channels, so estimate how much of GND is in VBUS (least squares) and subtract it:

def apply_cmr(vbus, gnd):
    v = vbus.astype(np.float64); g = gnd.astype(np.float64)
    v -= np.mean(v); g -= np.mean(g)
    alpha = np.mean(v * g) / np.mean(g * g)   # ≈ 0.40 here
    return v - alpha * g

This is why the GND channel is in the capture: it’s the reference, the same idea as a differential probe.

There’s a subtle catch. VBUS really carries 0.6× the hum that’s on GND, but the estimate comes out at 0.40. GND carries its own noise, and least squares shrinks the estimate by hum power / (hum power + noise power). With a 30 mV hum and 15 mV of GND noise, that’s (0.03²/2) / (0.03²/2 + 0.015²) = 2/3, and 0.6 × 2/3 = 0.40. So about a third of the hum survives step 1.

Step 2: a 50 Hz notch. A narrow IIR notch (Q = 30, about 1.7 Hz wide) removes what’s left of the hum without touching the ~1 ms pulses:

def notch_50hz(signal):
    w0 = 2.0 * math.pi * 50 / A_SR
    alpha = math.sin(w0) / 60.0          # Q = 30
    b = np.array([1.0, -2.0 * math.cos(w0), 1.0])
    a = np.array([1.0 + alpha, -2.0 * math.cos(w0), 1.0 - alpha])
    ...

After these two steps you can see the pulses by eye (panel 3), but single samples are still noisy.

Step 3: matched filter. When you know the pulse shape, the best linear detector in white noise is to correlate the signal with that shape. That uses every sample of the pulse instead of one. Measure the pulse width from a zoomed plot (~1 ms), build the template, and correlate:

def make_ricker_template():
    half = int(3 * PULSE_TAU * A_SR)
    n = np.arange(-half, half + 1) / (PULSE_TAU * A_SR)
    h = (1 - n * n) * np.exp(-n * n / 2)
    return h - np.mean(h)

mf = np.correlate(signal - np.mean(signal), templ, mode='same')
peaks = [t0 + np.argmax(mf[t0:t0 + SYM_N]) for t0 in range(0, n_syms * SYM_N, SYM_N)]

Panel 4 shows one clean peak per slot. Compare each peak’s offset to 4.5 ms (exactly halfway between 2 and 7 ms), decode the Manchester pairs, find 0xEB90, and check the CRC:

window-min:
  FAILED
matched filter:
  Securinets{usb_p0w3r_l1n3_3xf1ltr4t10n}
kalman+MF:
  Securinets{usb_p0w3r_l1n3_3xf1ltr4t10n}
eb 90  Securinets{usb_p0w3r_l1n3_3xf1ltr4t10n}  a4
                                               └─ CRC-8/MAXIM, matches

Zero symbol errors out of 800.

An alternative: Kalman + matched filter. My solver has a second path. On the cleaned signal, a small 2-state Kalman filter (level and slope) tracks whatever slow baseline is left, and its normalised innovation, meaning the part the model didn’t predict, goes into the same matched filter. It works like an adaptive high-pass filter and also gets zero errors. TX doesn’t need it, but it helps when the interference isn’t a neat 50 Hz tone.


Takeaways

Thanks to everyone who played GCUP 2.0, and to Securinets and the IFT for hosting it. If you solved TX a different way, message me. I’d like to see it.


References

  1. P. Kocher, J. Jaffe, B. Jun, Differential Power Analysis, CRYPTO ‘99.
  2. Y. Su, D. Genkin, D. Ranasinghe, Y. Yarom, USB Snooping Made Easy: Crosstalk Leakage Attacks on USB Hubs, USENIX Security 2017.
  3. P. Cronin, X. Gao, C. Yang, H. Wang, Charger-Surfing: Exploiting a Power Line Side-Channel for Smartphone Information Leakage, USENIX Security 2021.
  4. M. Guri, B. Zadov, D. Bykhovsky, Y. Elovici, PowerHammer: Exfiltrating Data from Air-Gapped Computers through Power Lines, 2018.