From Code to Coverage (Part 2): The Whitespace Nightmare: Writing Sigma Rules That Actually Match

Glitch effectGlitch effectGlitch effect

Remember “From Code to Coverage: Part 1”? That deep-dive post where we spent all that time figuring out that Impacket tools use bitwise operations instead of OID values? Where we finally celebrated understanding that findDelegation.py searches for userAccountControl&524288?

Yeah, well...I've got bad news for you.

That was the easy part.

See, I thought I was done. I had my detection logic figured out. I understood the tools. I knew exactly what to look for. I deployed my rules to production with the confidence of someone who'd just solved a really hard puzzle. Then I got a Slack message at 2am: "Hey, we definitely saw Impacket activity during the red team exercise, but your rules didn't fire."

That's when I learned that understanding WHAT to detect (Part 1) is completely different from making it actually WORK in production (welcome to Part 2).

Grab a coffee. Or maybe something stronger. This is going to get frustrating.


Understanding the transformation: Why this mess exists

Before we dive into the nightmare, let me show you WHY this happens. Here's the actual flow of how your LDAP queries get transformed as they travel through Active Directory (based on my understanding from Part 1's deep dive into how Active Directory processes these queries):


Figure 1: The OID to Bitwise transformation flow - Where clean OID syntax goes to die and emerges as whitespace chaos

Key detection insights from this flow:

  1. Tools send OID syntax → AD transforms it before logging → proves tool behavior
  2. Active Directory (AD) transforms OID to bitwise → Event 1644 logs this → shows execution
  3. Detection rules must target bitwise operations (&), not OIDs
  4. THE CRITICAL POINT: The transformation in the LDAP Service Layer doesn't normalize whitespace!

The problem: Your logs are lying to you (sort of)

Now that you understand WHERE the transformation happens, here's the kicker: that LDAP Service Layer transformation? It doesn't give a damn about consistent formatting. The Filter Parser only cares if your query makes semantic sense—whitespace is irrelevant to the bitwise operation, so it gets preserved (or added, or removed) based on mysterious internal processing.

Here's what drove me absolutely insane for weeks. I had rules that targeted userAccountControl&524288, and they worked...sometimes. Like, 30% of the time. Which is worse than not working at all, because at least then you KNOW something's broken.

The problem? Event 1644 doesn't log LDAP filters with consistent whitespace. And when I say "doesn't," I mean it's completely random. I've seen all of these variations in real environments:

(userAccountControl&524288)

(userAccountControl & 524288)

( userAccountControl&524288 )

( userAccountControl & 524288 )

(userAccountControl& 524288)

( userAccountControl &524288)

Look at that mess. LOOK AT IT. That's the same exact query, six different ways.

Why does this happen? Look back at our flow diagram—the transformation happens in the LDAP Service Layer, but the exact spacing depends on:

  • How the original query was formatted by the client tool

  • Internal string processing routines in the AD engine (which, by the way, Microsoft hasn't documented)

  • Event logging formatting and serialization (also undocumented, thanks Microsoft)

  • Whether the query went through additional processing steps (you guessed it - undocumented)

So basically, it's chaos. Complete chaos happening right there in that green LDAP Service Layer box.


My journey through the five stages of grief (detection engineering edition)

Let me walk you through how my rules evolved as I slowly lost my mind over this problem.

Stage 1: Denial ("My rules are perfect.")

selection:

  winlog.event_data.LDAPFilter|contains: 'userAccountControl&524288'

This worked in my lab where I was using the same tool in the same way every time. "Ship it to production!" I said. "It's bulletproof!" I said.

30% hit rate in production.

I spent three days thinking our SIEM was broken. Spoiler: it wasn't. The LDAP Service Layer was just laughing at my assumptions.

Stage 2: Anger ("I'll just list every possibility.")

selection:

  winlog.event_data.LDAPFilter|contains|any:

    - 'userAccountControl&524288'

    - 'userAccountControl & 524288'

    - ' userAccountControl&524288'

    - 'userAccountControl&524288 '

Better, but still missing edge cases. Plus, this was getting ridiculous fast. I was basically trying to enumerate every possible spacing combination that could emerge from that transformation layer, which is like trying to list every way someone could possibly sneeze. It's impossible, and you look stupid trying.

Stage 3: Bargaining ("Maybe Regex will save me.")

selection:

  winlog.event_data.LDAPFilter|re: '.*userAccountControl\s*&\s*524288.*'

Much better! The \s* matches zero or more whitespace characters, so it handles most spacing variations. I thought I was clever. The universe (and the LDAP Service Layer) laughed at me. I was still missing hits because I wasn't accounting for parentheses spacing.


Stage 4: Depression ("Why did I choose this career?")

After all, I got a certificate from Gelato University, thinking I was escaping this chaos. Turns out, LDAP filters are harder to master than stracciatella.

Stage 5: Acceptance ("Fine, I'll handle everything.")

selection:

  winlog.event_data.LDAPFilter|re: '.*\(\s*userAccountControl\s*&\s*524288\s*\).*'

Now we're accounting for spaces around the parentheses too. This finally gave me consistent hit rates in production. It only took me three weeks and probably a few years off my life.


From Part 1 theory to Part 2 reality: A comparison

Remember how confident we were at the end of Part 1? Let's see how that worked out:

What Part 1 taught us

What Part 2 slapped us with

GetUserSPNs.py uses userAccountControl&2 (in the !(userAccountControl&2) exclusion filter)

Could appear as userAccountControl & 2 or twenty other ways

findDelegation.py uses &16777216 and &524288

Might show up with random spacing that changes based on the phase of the moon

OIDs get transformed to bitwise operations

Those operations have infinite formatting variations after transformation

Event 1644 shows the LDAP filter

But doesn't normalize the formatting because that would be helpful

The transformation happens in the LDAP Service Layer

That layer has zero documentation about whitespace handling


Building Sigma rules that actually work (and won't make you cry)

After years of pain, here are the patterns that actually work in production:

For simple Bitwise operations:

# Handles: userAccountControl&524288 with any spacing

# This is what Part 1 taught us to look for, Part 2 makes it actually work

selection:

  winlog.event_data.LDAPFilter|re: '.*userAccountControl\s*&\s*524288.*'


For complex multi-condition filters:

Remember findDelegation.py from Part 1? Here's how to actually catch it after it goes through the transformation:

# findDelegation.py detection with spacing tolerance

# Part 1: "Look for these flags"

# Part 2: "Look for these flags in 47 different formats"

selection:

  winlog.event_data.LDAPFilter|re: '.*userAccountControl\s*&\s*16777216.*'

  winlog.event_data.LDAPFilter|re: '.*userAccountControl\s*&\s*524288.*'

condition: all of selection


For attribute lists with spacing issues:

# Handles spacing in attribute lists

# Because of course attribute lists have spacing issues too

selection:

  winlog.event_data.AttributeList|re: '.*msDS-PrincipalName\s*,\s*objectSid.*'


For negation filters:

# Handles: !(userAccountControl&2) with spacing

# Yes, even the exclamation point can have spaces after it

# I wish I was joking

selection:

  winlog.event_data.LDAPFilter|re: '.*!\s*\(\s*userAccountControl\s*&\s*2\s*\).*'


Advanced patterns for when basic Regex isn't enough

Case sensitivity (because why would anyone be consistent?):

I've seen userAccountControl, UserAccountControl, and even useraccountcontrol. In the same environment. On the same day. The LDAP Service Layer doesn't care about case either.

# Case-insensitive bitwise detection

# For when admins can't agree on capitalization

selection:

  winlog.event_data.LDAPFilter|re: '(?i).*useraccountcontrol\s*&\s*524288.*'


Multiple operators (When "&" isn't the only problem):

Some tools use bitwise OR instead of AND, because standardization is for quitters:

# Catches both & and | operations

selection:

  winlog.event_data.LDAPFilter|re: '.*userAccountControl\s*[&|]\s*524288.*'



Testing your Regex (or: How I learned to stop trusting and verify everything)

Here's my battle-tested process:

1. Collect real data (not lab data)

Lab data is a lie. It's too clean. Too consistent. Run your target tools in production (safely!) and collect actual Event 1644 logs. Watch them go through that transformation layer in real-time.

2. Create a "Wall of Shame" test file

I literally have a file called whitespace_hell.txt with every variation I've ever seen:

(userAccountControl&524288)

(userAccountControl & 524288)

( userAccountControl&524288 )

( userAccountControl & 524288 )

(userAccountControl& 524288)

( userAccountControl &524288)

3. Test your Regex against the wall

Use regex101.com or whatever tool you prefer. If your pattern doesn't match every line in your Wall of Shame, it's not ready for production.

4. Deploy with paranoid logging

Enable detailed logging so you can see what you're matching and what you're missing. You will be missing something. Accept it now.


Common pitfalls (learn from my pain)
Pitfall 1: "This worked in the lab."

# Too specific - will miss 70% of legitimate hits

selection:

  winlog.event_data.LDAPFilter|contains: '(userAccountControl&524288)'

Fix: Always use regex with whitespace tolerance. Always.


Pitfall 2: "I'll cast a wide net."

# Too broad - will match your grandmother's grocery list

selection:

  winlog.event_data.LDAPFilter|re: '.*524288.*'

Fix: Include the attribute name and operator. Context matters.


Pitfall 3: "I know how Regex works."

# Wrong - parentheses have special meaning in regex

selection:

  winlog.event_data.LDAPFilter|re: '(userAccountControl&524288)'


# Right - escape the parentheses or cry later

selection:

  winlog.event_data.LDAPFilter|re: '\(userAccountControl&524288\)'


Pitfall 4: "Windows is case-insensitive, right?"

Sometimes. Maybe. It depends. The LDAP Service Layer might preserve case, might not. Just make everything case-insensitive and move on with your life.

Pitfall 5: "I'll just look for the OID."

# THIS WILL NEVER WORK

selection:

  winlog.event_data.LDAPFilter|contains: '1.2.840.113556.1.4.803'

Look at the flow diagram! The OID is transformed to & in the LDAP Service Layer BEFORE it gets to Event 1644. You're searching for something that doesn't exist in these logs!


Performance (because your SIEM admin is already mad at you)

Regex patterns can murder your SIEM performance. Here's how to not get fired:

Pre-filter before Regex:

# Fast string match first, expensive regex second

selection_prefilter:

  winlog.event_data.LDAPFilter|contains: 'userAccountControl'

selection_regex:

  winlog.event_data.LDAPFilter|re: '.*userAccountControl\s*&\s*524288.*'

condition: selection_prefilter and selection_regex

Your SIEM admin will thank you. Or at least stop glaring at you in meetings.


The actual production rule (battle-tested and tear-stained)

After all this pain, here's the actual Sigma rule I use for findDelegation.py. Notice how it handles multiple spacing variations—this isn't paranoia, it's experience:

Note: The following are detection logic excerpts showing the key selection criteria. Field names use Elastic Common Schema (ECS). Adjust for your SIEM. The Source filter matches remote IP:port connections, filtering out local DC operations.

detection:

 selection_event:

    event.code: 1644 

  selection_attrs:

    winlog.event_data.AttributesRequested|contains|all:

      - sAMAccountName

      - pwdLastSet

      - userAccountControl

      - objectCategory

      - msDS-AllowedToActOnBehalfOfOtherIdentity

      - msDS-AllowedToDelegateTo

  selection_delegation_attrs:

    winlog.event_data.LDAPFilter|contains|all:

      - (msDS-AllowedToDelegateTo=*)

      - (msDS-AllowedToActOnBehalfOfOtherIdentity=*)

  selection_excl_8192:

    winlog.event_data.LDAPFilter|contains:

      - (!(userAccountControl&8192))

      - ( ! (userAccountControl&8192) )

      - ( !  (userAccountControl&8192) )

  selection_excl_disabled:

    winlog.event_data.LDAPFilter|contains:

      - (!(userAccountControl&2))

      - ( ! (userAccountControl&2) )

      - ( !  (userAccountControl&2) )

  selection_src:

    winlog.event_data.Source|re: '^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}:\d{1,5}$'

  selection_uac_bits:

    winlog.event_data.LDAPFilter|contains|all:

      - userAccountControl&16777216

      - userAccountControl&524288

condition: selection_event and selection_uac_bits and selection_delegation_attrs and selection_excl_disabled and selection_excl_8192 and selection_attrs and selection_src

Look at those test cases! The first one has zero spaces. The second one has spaces in places I didn't even know could HAVE spaces. This is why we need multiple variations in our detection logic.


Lessons learned (the hard way)

  1. Test with production data, not lab data. Your lab is a beautiful lie. Production is the ugly truth.
  2. Assume nothing about log formatting. Seriously. Nothing. That space you're counting on? It won't be there when you need it. The LDAP Service Layer doesn't care about your assumptions.
  3. Start with flexible patterns. You can tighten them later when you understand YOUR environment's specific chaos.
  4. Monitor your rules obsessively. That rule that worked for six months will randomly stop working on a Tuesday.
  5. Document everything. When you write a regex pattern at 2am after your fourth energy drink, document what you were trying to handle. Future you will thank past you.
  6. Make friends with your SIEM admin. You're going to break things. Having a friend helps.
  7. Understand the transformation flow. Know WHERE your logs come from and WHAT happens to them along the way. The OID → bitwise transformation is just one example of many transformations that can mess with your detection logic.


Final thoughts: The detection engineering reality check

In Part 1, we learned to think like an attacker—understanding how Impacket tools construct their LDAP queries. We felt smart. We felt accomplished.

In Part 2, we learned to think like a log parser having an existential crisis—handling every possible variation those queries might take after going through the transformation gauntlet. We feel tired. We feel humbled.

The OID to Bitwise Transformation Flow shows us exactly WHERE things go wrong—that LDAP Service Layer is where our clean, predictable OID syntax gets transformed into the whitespace chaos we see in Event 1644. Understanding this flow matters because:

  • It explains why you'll NEVER find OIDs in Event 1644 (they're already transformed)

  • It shows why whitespace is so inconsistent (the transformation doesn't normalize it)

  • It demonstrates why testing in production is essential (you can't predict the transformation variations)

For now, though, take a break. Update your rules to handle whitespace. Test them against your Wall of Shame. And remember, every senior detection engineer has been through this exact same journey. We're all in this whitespace nightmare together.

And if someone tells you their LDAP detection rules work perfectly in production on the first try? They're lying. Or they haven't checked their detection rates. Or they haven't looked at the transformation flow. Probably all three.

Stay strong, my friends. The logs are messy, but we'll make sense of them together.

p.s. If you're wondering why Microsoft doesn't just normalize the whitespace in the LDAP Service Layer before logging...join the club. I mostly just cry.



Thanks to Jonathan JohnsonCharlie ClarkAnton OvrutskyMatt AndersonTyler BohlmannLindsey O'Donnell-WelchMichael HaagNasreddine Bencherchali, and Adam Bienvenu for their help in reviewing this post.





Sign Up for Huntress Updates

Get insider access to Huntress tradecraft, killer events, and the freshest blog updates.

By submitting this form, you accept our Terms of Service & Privacy Policy
Oops! Something went wrong while submitting the form.
Huntress at work