How to Detect Hidden Unicode Payloads (GlassWorm / Trojan Source)

Intermediate 10 minutes Security Engineers Security & Policy

Block packages carrying zero-width, bidi-override, or Unicode-tag payloads with the hasHiddenUnicode condition.

Overview

GlassWorm (October 2025) and the Trojan Source class of attacks (2021) embed non-printing Unicode characters inside source code. A code reviewer sees one thing; the compiler / interpreter sees another. Three ranges cover the vast majority of the attack surface:

  • Zero-width — U+200B (ZWSP), U+200C (ZWNJ), U+200D (ZWJ), U+200E / U+200F (LRM / RLM), U+2060–U+2064 (word-joiner family). Used to hide characters from visual inspection while still affecting string comparisons and code execution.
  • Bidi-override — U+2028–U+202E (LRE / RLE / PDF / LRO / RLO), U+2066–U+2069 (LRI / RLI / FSI / PDI). Lets attackers reorder a line so comments appear to contain safe code when the compiler actually sees a dangerous branch.
  • Tag characters — U+E0000–U+E007F. Invisible in most editors but processed by language runtimes that accept Unicode identifiers.

Chainsaw runs a static byte-scan over the unpacked artifact’s text files and sets package_metadata.hidden_unicode_hits. The hasHiddenUnicode condition fires when hits exceed a configurable threshold.

Prerequisites

  • Admin or Manager role in Chainsaw
  • At least one source-bearing ecosystem proxied (npm, PyPI, RubyGems, Cargo, Composer, Go, NuGet, Maven, Gradle, Swift)

Step 1: Tune the Scan Envelope

The scan is bounded to keep the install path fast. Adjust via environment variables if the defaults don’t fit your package size distribution:

VariableDefaultPurpose
CHAINSAW_HIDDEN_UNICODE_MAX_FILES500Stop after this many text files per artifact
CHAINSAW_HIDDEN_UNICODE_MAX_BYTES50 MiBStop after this total byte budget per artifact
CHAINSAW_HIDDEN_UNICODE_THRESHOLD1Hits required before hasHiddenUnicode = true

The defaults catch every hidden-Unicode attack we’ve seen published while keeping the scan under the sync-check budget on the install path.

Step 2: Create a Block Policy

  1. Navigate to Policies → Create Policy
  2. Name: Block — hidden Unicode
  3. Condition: hasHiddenUnicode = true
  4. Action: Block
  5. Precedence: High (above any license / vuln allowlist)

Unlike publisherChanged, there’s no reason to run this in monitor mode first — legitimate packages don’t ship bidi-override or tag characters inside source code. False positives in practice are near zero on production ecosystems.

Step 3: Narrow by Kind if Needed

If you see a noise source in your baseline — for example, a package that includes RTL language samples and legitimately uses bidi markers in test fixtures — narrow the block to the two highest-signal kinds:

conditions:
  hasHiddenUnicode: true
  hiddenUnicodeKinds: [zero_width, tag]

The match is intersection semantics: at least one of the kinds you list must appear in the scanner’s kinds output. Dropping bidi_override removes the legitimate-RTL false positives while still catching the Unicode-tag and zero-width vectors that have no legitimate use in code.

Step 4: CLI Verification

chainsaw pkg scan npm/@zenity/zen-components \
  --version 3.1.8 \
  --with-hidden-unicode

Output includes the hit count, kinds, file paths relative to the artifact root, and byte offsets — enough to reproduce the finding in any editor that reveals non-printing characters.

Ecosystem Support

EcosystemSupport
npm / PyPI / RubyGems / Cargo / Composer / Go / NuGet / Maven / Gradle / Swift
Hugging Face⚠️ Text files scanned, model weights skipped — signal may miss intent
CocoaPods❌ (artifact unpack not wired)
Docker❌ (layer text-file scan is a separate item)
APT / Yum / DNF❌ (OS-package control files, no source to walk)

Next Steps