How to Detect Hidden Unicode Payloads (GlassWorm / Trojan Source)
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:
| Variable | Default | Purpose |
|---|---|---|
CHAINSAW_HIDDEN_UNICODE_MAX_FILES | 500 | Stop after this many text files per artifact |
CHAINSAW_HIDDEN_UNICODE_MAX_BYTES | 50 MiB | Stop after this total byte budget per artifact |
CHAINSAW_HIDDEN_UNICODE_THRESHOLD | 1 | Hits 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
- Navigate to Policies → Create Policy
- Name:
Block — hidden Unicode - Condition:
hasHiddenUnicode = true - Action: Block
- 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
| Ecosystem | Support |
|---|---|
| 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) |