Hunting Invisible Unicode in Emails (+ KQL Queries)

For years, when analyzing phishing emails, I have mainly focused on one target: the user or/and the mailbox. What does the user see? What can make them click? What can make them trust the sender? I think AI agents are going to change this.

More and more emails, documents, websites and messages are being processed by AI before or together with the user. Today an AI assistant can summarize an email or analyze a document. In the near future, I think it will be normal for almost every user or mailbox to have an AI agent behind it, reading content, prioritizing messages, checking links and potentially taking actions. Attackers will adapt to this too.

We are already talking a lot about prompt injection and hidden instructions. For me, one interesting hunting area is content that the human doesn’t easily see, but that is still there for a system, parser or AI agent to process. This brought me to something much simpler: Unicode characters.

Invisible doesn’t mean it isn’t there

But why would an attacker be interested in invisible characters? One simple reason is keyword evasion.

By inserting an invisible character inside a word, an attacker can potentially break exact string matching used by detections, filters or other systems while keeping the word visually unchanged for the user.

For example, a system looking for Microsoft may not necessarily match Micro<U+200B>soft. The user sees the same word. The system may see a different string.

One good example is U+200B, the Zero Width Space.These two strings can look the same:

Microsoft
Microsoft

But the second one could actually be:

Micro<U+200B>soft

For the user, Microsoft is Microsoft. For the system processing the string, they are not necessarily the same value. There are several interesting Unicode ranges we can look for:

U+200B–U+200F
U+202A–U+202E
U+2060–U+206F
U+FEFF

They include zero-width characters, directional marks and other formatting/control characters. Of course, finding one doesn’t mean that we found an attack. Some of these characters have completely legitimate uses. As usual in threat hunting, the interesting part is the context and enrich with another indicators or patterns.

Hunting them in email

I started with two fields: Subject and SenderDisplayName. Both are interesting because they are information presented directly to the user, but at the same time they are strings that will be processed by security products, parsers and potentially AI systems. Instead of looking only for the Unicode character, I wanted to add some context and create a simple risk score. My current idea is:

  • Unicode symbol/icon in Subject : +40
  • Hidden Unicode in Subject: +20
  • Hidden Unicode in SenderDisplayName: +20
  • Same pattern observed from multiple countries: +20

The KQL looks like this:

EmailEvents
| where Timestamp > ago(1d)
| where isnotempty(Subject)
| extend SenderIP = iff(isnotempty(SenderIPv4), SenderIPv4, SenderIPv6)
| extend Country = tostring(geo_info_from_ip_address(SenderIP).country)
| extend SubjectSuspiciousChars = extract_all(@“([\u200B-\u200F\u202A-\u202E\u2060-\u206F\uFEFF])”, Subject)
| extend SenderDisplaySuspiciousChars = extract_all(@“([\u200B-\u200F\u202A-\u202E\u2060-\u206F\uFEFF])”, SenderDisplayName)
| extend HasEmoji = Subject matches regex @“[\u2600-\u27BF]”
| extend SubjectUnicode = iff(array_length(SubjectSuspiciousChars) > 0, 1, 0), SenderDisplayUnicode = iff(array_length(SenderDisplaySuspiciousChars) > 0, 1, 0)
| summarize Timestamp=max(Timestamp), HasEmoji=max(toint(HasEmoji)), SubjectUnicode=max(SubjectUnicode), SenderDisplayUnicode=max(SenderDisplayUnicode), SubjectSuspiciousChars=make_set(SubjectSuspiciousChars), SenderDisplaySuspiciousChars=make_set(SenderDisplaySuspiciousChars), Countries=make_set(Country), SenderIPs=make_set(SenderIP), SenderDisplayNames=make_set(SenderDisplayName), SenderAddresses=make_set(SenderFromAddress), SenderDomains=make_set(SenderFromDomain), DeliveryLocations=make_set(LatestDeliveryLocation), ThreatTypes=make_set(ThreatTypes), ThreatNames=make_set(ThreatNames), EmailCount=count() by Subject, NetworkMessageId, RecipientEmailAddress
| extend CountryCount = array_length(Countries)
| extend MultiCountry = iff(CountryCount > 1, 1, 0)
| extend RiskScore = (HasEmoji * 40) + (SubjectUnicode * 20) + (SenderDisplayUnicode * 20) + (MultiCountry * 20)
| extend RiskReasons = strcat(iff(HasEmoji == 1, "Unicode symbol in Subject [+40]; ", “”), iff(SubjectUnicode == 1, “Hidden Unicode in Subject [+20]; “, “”), iff(SenderDisplayUnicode == 1, “Hidden Unicode in SenderDisplayName [+20]; “, “”), iff(MultiCountry == 1, strcat(“Multiple Countries [+20] (”, CountryCount, “); “), “”))
| where RiskScore > 0
| project Timestamp, NetworkMessageId, RecipientEmailAddress, RiskScore, RiskReasons, Subject, SubjectSuspiciousChars, SenderDisplayNames, SenderDisplaySuspiciousChars, Countries, CountryCount, SenderIPs, SenderAddresses, SenderDomains, DeliveryLocations, EmailCount, ThreatTypes, ThreatNames
| order by RiskScore desc, Timestamp desc

One small clarification about this line:

| extend HasEmoji = Subject matches regex @”[\u2600-\u27BF]”

I’m currently looking at the Unicode range U+2600–U+27BF. This contains many symbols commonly used visually in subjects, such as :warning:, :skull_and_crossbones:, :envelope:, :check_mark:, :multiply:, :cross_mark: and others. It doesn’t cover every modern emoji, so I prefer to think about this signal as Unicode symbols/icons in the Subject, rather than pretending it is a complete emoji detector. In addition, as I wrote in the CYBER Hunter Project and in an article, Emojis in Email Subjects are potential cases of Spam or Phishing :right_arrow: https://www.linkedin.com/pulse/threat-hunting-scenario-catching-emojis-files-email-subjects-albea-n6ire

Case 1: Subject

The Subject was my first test because this is an obvious place to play with what the user sees. For example:

Microsoft Password Expired

and:

Microsoft Password Expired

They look almost identical, but if there is a U+200B between Micro and soft, the underlying strings are different. The query extracts these characters with:

extract_all(@”([\u200B-\u200F\u202A-\u202E\u2060-\u206F\uFEFF])”, Subject)

I give this +20. I also look for Unicode symbols/icons in the Subject and give that +40. Why more weight to the icon? Not because an icon is more malicious than a hidden character. It is simply the scoring model I’m testing right now, and the weights can be changed after looking at enough real data and false positives.

Case 2: SenderDisplayName

Then I applied exactly the same idea to SenderDisplayName. This field is very interesting in phishing because users often pay much more attention to:

Microsoft Support

than to the real sender address behind it. So I look for the same invisible Unicode characters:

extract_all(@”([\u200B-\u200F\u202A-\u202E\u2060-\u206F\uFEFF])”, SenderDisplayName)

Again, this doesn’t make the sender malicious. But if I have an unusual Subject, hidden characters in the Subject, hidden characters in the display name and other strange context around the sender, I definitely want to look at that email.

Adding the sender infrastructure

I also wanted some basic infrastructure context, so I’m taking the sender IP and getting the country:

| extend SenderIP = iff(isnotempty(SenderIPv4), SenderIPv4, SenderIPv6)
| extend Country = tostring(geo_info_from_ip_address(SenderIP).country)

If the pattern is associated with multiple countries, another +20 is added. There can be perfectly legitimate reasons for this. Cloud mail infrastructure, gateways and distributed services immediately come to mind. That’s why, again, this is a hunting signal and not a verdict.

I’m also keeping NetworkMessageId and RecipientEmailAddress in the output because I want this query to be usable as the base for a Defender XDR Custom Detection, not only something I run manually once.

Summary

The KQL itself is quite simple. What interests me more is where this type of hunting could go. Today I can use these characters to find unusual email patterns, possible obfuscation or attempts to make strings look different to detection systems while looking normal to a person. But now add AI agents to the picture. An email may soon have two audiences:

Human + AI Agent

The human sees the rendered message. The agent may receive extracted text, HTML, metadata or another representation of exactly the same content. That difference is something attackers will probably explore.

Unicode is only one small example. Hidden HTML, zero-size text, white text on white backgrounds, unusual formatting and instructions that are available in the source but practically invisible in the rendered content are other areas I’m interested in hunting.

Because if AI agents become part of almost every mailbox (and this is where I think we are heading), attackers will not only write emails for us. They will start writing emails for our agents too.

Hunting Invisible Unicode in Emails (+ KQL Queries) was originally published in Detect FYI on Medium, where people are continuing the conversation by highlighting and responding to this story.

Introduction to Malware Binary Triage (IMBT) Course

Looking to level up your skills? Get 10% off using coupon code: MWNEWS10 for any flavor.

Enroll Now and Save 10%: Coupon Code MWNEWS10

Note: Affiliate link – your enrollment helps support this platform at no extra cost to you.

Article Link: https://detect.fyi/hunting-invisible-unicode-in-emails-kql-queries-71722628161c?source=rss----d5fd8f494f6a---4