Skip to main content

Overview

Traffic padding adds random bytes to beacon payloads to obscure actual message sizes and defeat network fingerprinting systems that identify C2 traffic by analyzing consistent payload lengths.

Why Padding Matters

Network security tools often detect C2 beacons by identifying fixed payload size patterns:
Beacon Payloads:
  Beacon 1: 247 bytes
  Beacon 2: 247 bytes  ← Same size
  Beacon 3: 247 bytes  ← Same size
  Beacon 4: 247 bytes  ← Same size

⚠️ Consistent sizes create detectable signature
Padding is applied per request. Each beacon generates a new random pad length within the configured min/max range.

Padding Algorithm

The padding system uses a length-prefix protocol:
# Source: evasion/padding_strat.py:8-19
def pad(plaintext: bytes, min_bytes: int, max_bytes: int) -> bytes:
    # Prepend a 2-byte length prefix and random pad bytes to plaintext.
    if min_bytes > max_bytes:
        raise ValueError(
            f"invalid padding range: min_bytes ({min_bytes}) > max_bytes ({max_bytes})"
        )

    pad_len   = random.randint(min_bytes, max_bytes) if max_bytes > 0 else 0
    pad_bytes = os.urandom(pad_len)

    # length prefix lets strip_padding know exactly how many bytes to skip
    return struct.pack('>H', pad_len) + pad_bytes + plaintext

Protocol Structure

┌─────────────┬──────────────────┬─────────────────┐
│   2 bytes   │   N bytes        │   M bytes       │
│  (uint16)   │  (random pad)    │  (plaintext)    │
├─────────────┼──────────────────┼─────────────────┤
│  pad_len=N  │  os.urandom(N)   │  actual message │
└─────────────┴──────────────────┴─────────────────┘
      ↑              ↑                   ↑
   Header        Random Bytes       Original Data
Key Details:
  • Header: 2-byte big-endian uint16 stores pad length (0-65535)
  • Padding: Cryptographically random bytes from os.urandom()
  • Plaintext: Original beacon message appended after padding

Stripping Padding

The server-side strips padding using the length prefix:
# Source: evasion/padding_strat.py:22-40
def strip_padding(padded: bytes) -> bytes:
    # Remove the padding prepended by pad() and return the original plaintext.
    if len(padded) < HEADER_SIZE:
        raise ValueError(
            f'strip_padding: input too short to contain length prefix '
            f'(got {len(padded)} bytes, need at least {HEADER_SIZE})'
        )

    pad_len = struct.unpack('>H', padded[:HEADER_SIZE])[0]

    expected_min = HEADER_SIZE + pad_len
    if len(padded) < expected_min:
        raise ValueError(
            f'strip_padding: input too short — '
            f'header claims {pad_len} pad bytes but only '
            f'{len(padded) - HEADER_SIZE} bytes follow the header'
        )

    return padded[HEADER_SIZE + pad_len:]

Strip Process

# Example: Strip padding from received beacon
padded_beacon = b'\x00\x2a' + b'\xff' * 42 + b'{"beacon_id":"abc123"}'
#                 ^^^^^^       ^^^^^^^^^^^^   ^^^^^^^^^^^^^^^^^^^^^^
#                 pad_len=42   42 random      actual JSON message

plaintext = strip_padding(padded_beacon)
print(plaintext)  # b'{"beacon_id":"abc123"}'

Padding Ranges

Traffic profiles define min/max padding bounds:
padding_min: 0
padding_max: 0
Behavior: Only 2-byte header added, pad_len=0
>>> pad(b'hello', 0, 0)
b'\x00\x00hello'  # 2-byte zero header + plaintext
#  ^^^^^^ pad_len=0
No padding provides no size obfuscation. Use only for testing.

Padding Characteristics

Cryptographic Randomness

Padding bytes use os.urandom() for unpredictable content:
pad_bytes = os.urandom(pad_len)
Properties:
  • No patterns or repetition
  • Passes entropy tests
  • Defeats content-based fingerprinting
  • Suitable for cryptographic applications

Dynamic Per-Request

Each beacon generates new random padding:
# Two beacons with same plaintext → different total sizes
msg = b'{"status": "alive"}'

beacon1 = pad(msg, 32, 128)  # 2 + 67 + 19 = 88 bytes
beacon2 = pad(msg, 32, 128)  # 2 + 103 + 19 = 124 bytes

assert len(beacon1) != len(beacon2)  # Different sizes
assert strip_padding(beacon1) == strip_padding(beacon2) == msg  # Same plaintext

Implementation Details

Header Size Constant

HEADER_SIZE = 2  # 2-byte uint16 big-endian length prefix
The 2-byte header limits pad_len to 0-65535 bytes (64 KB max).

Range Validation

if min_bytes > max_bytes:
    raise ValueError(
        f"invalid padding range: min_bytes ({min_bytes}) > max_bytes ({max_bytes})"
    )
Configuration errors are caught at pad-time with clear error messages.

Zero-Padding Behavior

When max_bytes=0, padding is skipped entirely:
pad_len = random.randint(min_bytes, max_bytes) if max_bytes > 0 else 0

Usage Examples

from evasion.padding_strat import pad, strip_padding

# Client-side: Pad outgoing beacon
plaintext = b'{"beacon_id": "xyz789", "status": "active"}'
padded = pad(plaintext, min_bytes=32, max_bytes=128)

# Send padded beacon over network
send_to_server(padded)

# Server-side: Strip padding
received = recv_from_client()
original = strip_padding(received)

print(original)  # b'{"beacon_id": "xyz789", "status": "active"}'

Bandwidth Impact

Overhead Calculation

Padding increases network usage:
ProfileMin OverheadMax OverheadAverage Overhead
baseline2 bytes2 bytes2 bytes
low2 bytes66 bytes34 bytes
medium2 bytes130 bytes66 bytes
high66 bytes258 bytes162 bytes
Average Overhead = 2 + (padding_min + padding_max) / 2

Example: 200-byte Beacon

base_message = 200  # bytes

# Without padding (baseline)
baseline_total = 2 + 0 + 200 = 202 bytes

# With medium padding (0-128)
medium_avg = 2 + 64 + 200 = 266 bytes  # +31.7% overhead

# With high padding (64-256)
high_avg = 2 + 160 + 200 = 362 bytes  # +79.2% overhead

Error Handling

The implementation includes comprehensive error checking:
>>> pad(b'test', min_bytes=100, max_bytes=50)
ValueError: invalid padding range: min_bytes (100) > max_bytes (50)
Fix: Ensure min ≤ max in profile configuration
>>> strip_padding(b'\x00')  # Only 1 byte, need 2
ValueError: strip_padding: input too short to contain length prefix
            (got 1 bytes, need at least 2)
Cause: Network corruption or incomplete transmission
>>> bad = struct.pack('>H', 200) + b'\x00' * 10
>>> strip_padding(bad)  # Claims 200 pad bytes, only 10 present
ValueError: strip_padding: input too short —
            header claims 200 pad bytes but only 10 bytes follow
Cause: Data corruption or protocol mismatch

Testing Padding

Round-Trip Validation

original = b'secret beacon message'

# Pad with various ranges
for min_b, max_b in [(0, 64), (64, 128), (128, 256)]:
    padded = pad(original, min_b, max_b)
    recovered = strip_padding(padded)
    
    assert recovered == original, "Round-trip failed!"
    print(f"✓ Range [{min_b}, {max_b}]: {len(padded)} bytes total")

Length Verification

from evasion.padding_strat import HEADER_SIZE

# Verify padded length is within expected range
for _ in range(100):
    padded = pad(b'test', 10, 100)
    
    min_expected = HEADER_SIZE + 10 + len(b'test')  # 2 + 10 + 4 = 16
    max_expected = HEADER_SIZE + 100 + len(b'test') # 2 + 100 + 4 = 106
    
    assert min_expected <= len(padded) <= max_expected

Randomness Verification

# Verify padding is random, not deterministic
samples = [pad(b'test', 32, 32) for _ in range(10)]

# All should have same length (2 + 32 + 4 = 38)
assert all(len(s) == 38 for s in samples)

# But different content (random pad bytes)
assert len(set(samples)) > 1, "Padding should be random!"

Operational Recommendations

Bandwidth Available

Use medium or high profiles for maximum obfuscationPadding overhead is negligible for most operations

Bandwidth Constrained

Use low profile for minimal overheadStill provides size variance without heavy bandwidth cost

Long-Running Beacons

Use high profile with guaranteed minimum paddingPrevents statistical analysis over many samples

Large Payloads

Padding has less relative impact on large messages256 bytes padding on 10KB message = only 2.5% overhead
Profile Changes: Changing padding ranges mid-operation may create detectable pattern shifts. Maintain consistent padding configuration throughout an operation.

Combining with Other Evasion

Padding works best alongside:
  1. Jitter Strategies: Obscures both timing AND size patterns
  2. Header Randomization: Prevents correlation via User-Agent
  3. Encryption: Ensures pad bytes are indistinguishable from ciphertext
# Full evasion stack
from transport.traffic_profile import load_active_profile

profile = load_active_profile()

# 1. Encrypt message
encrypted = encrypt(plaintext)

# 2. Add random padding
padded = pad(encrypted, profile.padding_min, profile.padding_max)

# 3. Send with randomized headers (handled by transport layer)
# 4. Sleep with jitter before next beacon

Traffic Profiles

Configure padding in profile YAML

Jitter Strategies

Combine with timing randomization

Evasion Overview

Complete evasion architecture

Build docs developers (and LLMs) love