9 minute read

Hello, cybersecurity enthusiasts and white hackers!

malware

Everybody talks about GPT and large language models today. But a transformer is not magic - it is a small, understandable function trained with plain gradient descent. In this post we build our own mini-GPT completely from scratch in PyTorch, train it on raw bytes of real binaries, and turn it into a practical malware-analysis tool: a packing / encryption detector based on perplexity (how “surprised” the model is by the bytes it sees).

No black-box tools, no pretrained weights, no cloud API. Around 600K parameters, trained on a GPU in under half a minute. Everything is practice-oriented and reproducible.

the idea

A binary file is just a sequence of bytes. A GPT is a next-token predictor: given the previous bytes, it outputs a probability distribution over the next byte. If we train it only on normal machine code, it learns the “grammar” of real code - which byte usually follows e8 (a call), which patterns opcodes form, how padding looks.

Then we feed it new bytes and measure its surprise (the negative log-likelihood, a.k.a. perplexity):

normal code -> low surprise, the model predicts the next byte well;
packed / encrypted / compressed bytes -> high surprise, the model has no idea, because such bytes are close to random.

This is conceptually related to Shannon entropy from part 6, but it is smarter: entropy only counts byte frequencies, while the GPT models the order of bytes - the actual structure of code. That makes it a stronger signal for triage.

environment and data

You do not need a fancy setup - a Linux box with Python 3 is enough. A GPU is optional: this model is tiny and also trains on a plain CPU in a couple of minutes. If you have an NVIDIA GPU, the same script uses it automatically and trains in seconds.

We only need PyTorch, numpy and matplotlib. Start with a clean virtual environment:

python3 -m venv env && source env/bin/activate
pip install numpy matplotlib

malware

Now install PyTorch. This is the one step that trips people up, so read it slowly. PyTorch ships in two flavors: a CPU-only build and a CUDA (GPU) build. If you just run pip install torch, on many systems you get the CPU-only one - it works, but it will never touch your GPU, and later you will wonder why training crawls.

To use an NVIDIA GPU (like the L20 I used here), do it in two steps:

# 1) check the GPU and its driver are visible to the OS:
nvidia-smi     # must print your card (e.g. "NVIDIA L20") and a driver version

malware

# 2) install the CUDA build of PyTorch (match your CUDA version, e.g. 12.4 -> cu124):
pip install torch --index-url https://download.pytorch.org/whl/cu124

malware

Copy the exact command for your CUDA version from the official page: pytorch.org/get-started. No NVIDIA GPU? Then just pip install torch and skip this - everything below still runs fine on CPU, only slower.

Now the important nuance - always verify that PyTorch really sees the GPU before you start training:

python3 -c "import torch; print(torch.cuda.is_available(), torch.cuda.get_device_name(0) if torch.cuda.is_available() else 'CPU')"
# GPU ready  ->  True NVIDIA L20
# CPU only   ->  False CPU

malware

If it prints True and your GPU name - you are good. If it prints False but you do have an NVIDIA GPU, you almost certainly installed the CPU-only build: reinstall with the --index-url line above. Our script then picks the device automatically with a single line - dev = "cuda" if torch.cuda.is_available() else "cpu" - so the exact same code runs on your GPU or your laptop CPU with no changes.

practical example.

For training data we do not need any malware. We take real, legitimate ELF binaries from /usr/bin as our corpus of “normal code”. To get a packed sample for the detector, we simulate a packer the honest way - a small original header followed by a compressed body (real packers leave the same footprint: a low-entropy stub and a high-entropy compressed/encrypted body):

import os, glob, lzma, numpy as np

def collect(limit=160):
    out = []
    for p in sorted(glob.glob("/usr/bin/*")):
        try:
            if os.path.islink(p) or not (8000 < os.path.getsize(p) < 300000):
                continue
            b = open(p, "rb").read()
            if b[:4] == b"\x7fELF":
                out.append(b)
        except OSError:
            pass
        if len(out) >= limit:
            break
    return out

def pack_like(b, rng):                       # defensive packer simulation
    h = int(rng.integers(512, 4096))         # keep a small original header/stub
    body = b[h:]
    k = int(len(body) * rng.uniform(0.5, 1.0))
    return b[:h] + lzma.compress(body[:k], preset=int(rng.integers(1, 7))) + body[k:]

the model: a mini-GPT from scratch

Here is the whole transformer. It is a decoder-only GPT with causal self-attention - the same architecture as the big models, just tiny. Vocabulary is 256 (one token per byte).

# bin_gpt.py
# byte-level mini-GPT for binary analysis
# author: @cocomelonc
# https://cocomelonc.github.io/malware/2026/07/25/malware-analysis-11.html
import torch, torch.nn as nn
from torch.nn import functional as F

T, n_emb, n_head, n_layer, V = 64, 128, 4, 3, 256   # ctx, embed, heads, layers, vocab(bytes)

class Head(nn.Module):                        # one self-attention head
    def __init__(self, hs):
        super().__init__()
        self.k = nn.Linear(n_emb, hs, bias=False)
        self.q = nn.Linear(n_emb, hs, bias=False)
        self.v = nn.Linear(n_emb, hs, bias=False)
        self.register_buffer("mask", torch.tril(torch.ones(T, T)))
    def forward(self, x):
        b, t, c = x.shape
        att = (self.q(x) @ self.k(x).transpose(-2, -1)) * c ** -0.5   # scaled dot-product
        att = att.masked_fill(self.mask[:t, :t] == 0, float("-inf"))  # causal: no peeking ahead
        att = F.softmax(att, -1)
        return att @ self.v(x)

class Block(nn.Module):                        # attention + feed-forward with residuals
    def __init__(self):
        super().__init__()
        hs = n_emb // n_head
        self.h = nn.ModuleList([Head(hs) for _ in range(n_head)])
        self.proj = nn.Linear(n_emb, n_emb)
        self.ff = nn.Sequential(nn.Linear(n_emb, 4 * n_emb), nn.ReLU(), nn.Linear(4 * n_emb, n_emb))
        self.l1, self.l2 = nn.LayerNorm(n_emb), nn.LayerNorm(n_emb)
    def forward(self, x):
        x = x + self.proj(torch.cat([h(self.l1(x)) for h in self.h], -1))
        return x + self.ff(self.l2(x))

class GPT(nn.Module):
    def __init__(self):
        super().__init__()
        self.tok = nn.Embedding(V, n_emb)      # byte -> vector
        self.pos = nn.Embedding(T, n_emb)      # position -> vector
        self.blocks = nn.Sequential(*[Block() for _ in range(n_layer)])
        self.lnf = nn.LayerNorm(n_emb)
        self.head = nn.Linear(n_emb, V)        # -> distribution over next byte
    def forward(self, idx, tgt=None):
        b, t = idx.shape
        x = self.tok(idx) + self.pos(torch.arange(t, device=idx.device))
        logits = self.head(self.lnf(self.blocks(x)))
        loss = None if tgt is None else F.cross_entropy(logits.view(-1, V), tgt.view(-1))
        return logits, loss
    @torch.no_grad()
    def generate(self, idx, k):
        for _ in range(k):
            l, _ = self(idx[:, -T:])
            p = F.softmax(l[:, -1, :], -1)
            idx = torch.cat([idx, torch.multinomial(p, 1)], 1)
        return idx

That is it - self-attention, residual connections, layer norm, a feed-forward block, and a linear head. The famous transformer, about 60 lines.

training

The training loop is the same three steps you use for any neural net: forward, loss.backward(), opt.step(). We build one long byte stream from normal binaries and sample random windows.

import numpy as np, time, math

dev = "cuda" if torch.cuda.is_available() else "cpu"
files = collect(160); rng = np.random.default_rng(0)
tr_files, te_files = files[:120], files[120:]
stream = np.frombuffer(b"".join(tr_files[:80]), np.uint8).astype(np.int64)[:3_000_000]
data = torch.tensor(stream, dtype=torch.long)
n = int(0.9 * len(data)); trd, vad = data[:n], data[n:]

def batch(split):
    d = trd if split == "tr" else vad
    ix = torch.randint(len(d) - T, (64,))
    x = torch.stack([d[i:i+T]   for i in ix])
    y = torch.stack([d[i+1:i+T+1] for i in ix])
    return x.to(dev), y.to(dev)

torch.manual_seed(0); model = GPT().to(dev)
opt = torch.optim.AdamW(model.parameters(), 3e-3)
for s in range(2001):
    xb, yb = batch("tr")
    _, loss = model(xb, yb)
    opt.zero_grad(); loss.backward(); opt.step()

Let’s run it. I trained on an NVIDIA L20, but the exact same script runs on any machine (it just falls back to CPU):

malware

malware

The validation perplexity drops from 287 down to ~14 bytes in about 25 seconds. Perplexity 14 means that, on average, the model narrows the next byte down to roughly 14 likely candidates out of 256 - it has genuinely learned the structure of machine code.

malware

did it actually learn code?

Let’s ask the model to generate bytes from scratch and look at them as hex:

seed = torch.tensor([[0x7f]], device=dev)
print(model.generate(seed, 64)[0].cpu().numpy().astype("uint8").tobytes().hex(" "))

malware

7f 92 00 f3 e8 a0 9c ff ff 58 e8 c2 49 ff ff 80 7c 24 02 74 2f 85 c9 0f 88 39 ff ff ff eb c0 b9

This is not random noise - it looks like real x86. We can literally read opcodes in it: e8 .. ff ff is a call rel32, 74 2f is je, 85 c9 is test ecx, ecx, 0f 88 is js, eb is a short jmp, b9 is mov ecx, imm32. Our tiny model learned the texture of compiled code purely from bytes, with no disassembler and no labels.

the payoff: packing detection by perplexity

Now the malware-analysis part. We measure how surprised the trained model is per file window. Normal code = low surprise; packed/encrypted body = high surprise.

@torch.no_grad()
def nll(byte_ints):                            # per-position negative log-likelihood
    model.eval()
    x = torch.tensor(byte_ints, dtype=torch.long, device=dev); out = []
    for i in range(0, len(x) - 1, T):
        ch = x[i:i+T+1]
        if len(ch) < 2: break
        lg, _ = model(ch[:-1].unsqueeze(0))
        lp = F.log_softmax(lg[0], -1); tg = ch[1:]
        out.append((-lp[torch.arange(len(tg)), tg]).cpu().numpy())
    return np.concatenate(out)

def winavg(a, w=512):                           # average surprise per 512-byte window
    return np.array([a[i:i+w].mean() for i in range(0, len(a) - w + 1, w)])

Plotting the per-window surprise for one normal binary and its packed version makes the effect obvious:

malware

The blue line (normal binary) stays low in the code sections. The red line (packed version) has a huge plateau of high surprise exactly where the compressed body sits - the model screams “I have never seen bytes like this”. Average surprise was 1.88 on normal regions vs 3.31 on packed regions.

Turned into a simple detector - mean surprise over the first 60 KB of each file - and evaluated on 80 held-out files (each in a normal and a packed variant):

def score(b):
    return nll(np.frombuffer(b[:60000], np.uint8).astype(np.int64)).mean()

malware

ROC-AUC = 0.994. A 600K-parameter transformer we wrote by hand, trained for 25 seconds, separates packed from unpacked binaries almost perfectly - and, unlike a plain entropy threshold, it reacts to unusual code order, not just byte frequency.

malware

I also added some colors to final script:

malware

malware

summary

component what it does
GPT (60 lines) decoder-only transformer, 256-byte vocab, from scratch
training loop next-byte prediction on normal code, ~25 s on a GPU
generate() proves it learned x86 texture (readable opcodes)
nll() / perplexity packing / anomaly detector, ROC-AUC 0.994

This is the whole point: a transformer is just a next-token predictor trained by gradient descent, and once you build one yourself it stops being a buzzword and becomes a tool. The exact same engine, scaled up, powers the LLM copilots appearing in SOCs today.

A few honest limitations. Perplexity flags unusual bytes, so legitimately compressed resources (icons, installers) will also look “surprising” - in production you combine this with PE/ELF section context and known-packer YARA rules. The model is also attackable: an adversary can pad a file with normal-looking code to lower average surprise. As always, one signal is never enough - use it as a fast lead, not a verdict.

In the next part we will go further and feed the model opcode sequences from a real disassembler (capstone) instead of raw bytes, which makes the “code grammar” even sharper for family classification.

I hope this post with practical examples is useful for malware researchers, reverse engineers and everyone interested in blue team and ML skills.

Malware analysis part 6: Shannon entropy
Malware analysis part 10: Practical PE parsing
Anti-DDoS: SYN flood detection
Attention Is All You Need (transformer paper)
Andrej Karpathy - nanoGPT
MalConv - malware detection from raw bytes
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