We use essential cookies to run the site (session, security, and your theme/language preferences). With your permission we also load embedded third-party content, such as YouTube videos. Cookie Policy
The shape-shifting trick that broke signature-based antivirus
Author(s):Elier Rodríguez García
Index
Introduction
In 1990, a piece of software called 1260 — later known as V2PX — appeared on the antivirus community's radar. It was unremarkable in what it did: it infected files and spread. What was remarkable was what it looked like: every copy of the virus was different. The bytes in one infected file bore no obvious resemblance to the bytes in the next. Yet both copies behaved identically.
The trick was a mutation engine — a small piece of code that re-encrypted the virus body with a different key on each infection and generated a new, unique decryption stub. Antivirus scanners of the era worked by hunting for a fixed signature, a short byte sequence known to appear in every copy of a given virus. If the body is encrypted differently each time, there is no fixed sequence to hunt for.
This technique is called polymorphism (from the Greek for many forms). The behavior — the damage — stays constant. Only the encoding changes. That asymmetry is the core insight: what a program does and what its bytes look like are two entirely different things. Any detection strategy that conflates them is fragile by design.
Try It
The demo below simulates a tiny polymorphic engine. The payload — what the code does — is fixed: it always XOR-encrypts the target string with a secret key. The mutation engine re-encodes the loader on every run: it picks a new random encryption key, shuffles harmless no-op instructions into the decryption stub, and swaps variable names.
<!-- Polymorphic malware demo — same payload, different bytes on each mutation -->
<p class="hint">Each click on <b>Mutate</b> re-encrypts the same payload with a new random key and shuffles in harmless no-op bytes — the hex dump changes completely. <b>Run behavior check</b> always recovers the same original string, because the <em>behavior</em> is invariant even when the bytes are not.</p>
<div class="panel">
<div class="col">
<div class="col-label">Payload source (behavior)</div>
<pre id="payload-box" class="code-box"></pre>
</div>
<div class="col">
<div class="col-label">Hex dump (bytes seen by scanner)</div>
<pre id="hex-box" class="code-box hex"></pre>
</div>
</div>
<div class="status" id="status">Click Mutate to generate a variant.</div>
<div class="btns">
<button id="mutate" type="button">Mutate</button>
<button id="behave" type="button">Run behavior check</button>
<button id="reset" type="button" class="ghost">Reset</button>
</div>
const i18nFmt = (s, v) => s.replace(/%(\w+)%/g, (_, k) => String(v[k] ?? ''));
// Polymorphic malware simulator — demonstrates that behavior stays constant
// while byte-level encoding changes completely on every mutation.
const TARGET = 'ATTACK'; // fixed payload target string — always the same
// Build the decryption stub source with a given key and nop count.
function buildPayload(key, nops) {
const lines = [];
lines.push('function decrypt(data, key) {');
for (let i = 0; i < nops; i++) lines.push(' ' + nops + '; // nop');
lines.push(' return data.map(b => b ^ key);');
lines.push('}');
return lines.join('\n');
}
// XOR-encrypt a string with the given key byte.
function xorEncrypt(str, key) {
return str.split('').map(c => c.charCodeAt(0) ^ key);
}
// Format a byte as two hex digits.
function toHex(b) { return b.toString(16).padStart(2, '0'); }
// Show the payload source in the left panel.
function renderPayload(key, nops) {
document.getElementById('payload-box').textContent =
'// Payload (decryption stub):\n' + buildPayload(key, nops);
}
// Render byte array with color coding by role.
function renderHex(bytes, keyByte, nopCount, dataStart) {
const box = document.getElementById('hex-box');
box.innerHTML = '';
bytes.forEach((b, i) => {
const sp = document.createElement('span');
sp.textContent = toHex(b);
if (i < 2) { sp.className = 'h-key'; }
else if (i < 2 + nopCount) { sp.className = 'h-nop'; }
else { sp.className = 'h-data'; }
box.appendChild(sp);
});
}
let currentKey = 0;
let currentNops = 0;
let currentBytes = [];
let mutationCount = 0;
// Generate a new polymorphic variant with a fresh random key and nop count.
function mutate() {
mutationCount++;
currentKey = Math.floor(Math.random() * 200) + 10;
currentNops = Math.floor(Math.random() * 6);
const encrypted = xorEncrypt(TARGET, currentKey);
// Byte layout: [key_lo, key_hi, nop*N, encrypted_data...]
currentBytes = [currentKey & 0xff, (currentKey >> 4) & 0xff];
for (let i = 0; i < currentNops; i++) currentBytes.push(0x90);
currentBytes.push(...encrypted);
renderPayload(currentKey, currentNops);
renderHex(currentBytes, currentKey, currentNops, 2 + currentNops);
setStatus(i18nFmt('Variant #%n% generated — bytes changed, behavior unchanged.', { n: mutationCount }), 'warn');
}
// Behavior analysis — recover original string regardless of mutation.
function behaviorCheck() {
if (!currentBytes.length) {
setStatus('Mutate first to generate a sample.', 'bad');
return;
}
const key = currentKey;
const encrypted = currentBytes.slice(2 + currentNops);
const decoded = encrypted.map(b => b ^ key);
const result = String.fromCharCode(...decoded);
setStatus(i18nFmt('Behavior detected: XOR-decrypt → "%result%" (same every time)', { result: result }), 'ok');
}
function setStatus(t, cls) {
const s = document.getElementById('status');
s.textContent = t;
s.className = 'status ' + cls;
}
document.getElementById('mutate').addEventListener('click', mutate);
document.getElementById('behave').addEventListener('click', behaviorCheck);
document.getElementById('reset').addEventListener('click', () => {
mutationCount = 0;
currentKey = 0;
currentNops = 0;
currentBytes = [];
document.getElementById('payload-box').textContent = '// Press Mutate to generate a new variant.';
document.getElementById('hex-box').innerHTML = '';
setStatus('Click Mutate to generate a variant.', '');
});
// Generate the first variant on load.
mutate();
<!-- Polymorphic malware demo — same payload, different bytes on each mutation -->
<p class="hint">Each click on <b>Mutate</b> re-encrypts the same payload with a new random key and shuffles in harmless no-op bytes — the hex dump changes completely. <b>Run behavior check</b> always recovers the same original string, because the <em>behavior</em> is invariant even when the bytes are not.</p>
<div class="panel">
<div class="col">
<div class="col-label">Payload source (behavior)</div>
<pre id="payload-box" class="code-box"></pre>
</div>
<div class="col">
<div class="col-label">Hex dump (bytes seen by scanner)</div>
<pre id="hex-box" class="code-box hex"></pre>
</div>
</div>
<div class="status" id="status">Click Mutate to generate a variant.</div>
<div class="btns">
<button id="mutate" type="button">Mutate</button>
<button id="behave" type="button">Run behavior check</button>
<button id="reset" type="button" class="ghost">Reset</button>
</div>