TL;DR: Detection engineering is sometimes hard. Your efforts may seem to have failed, but perseverance can pay off. Or you can still fail miserably, you decide 😉
Sad note: The detection shared has been working great when working on the research. Currently, it is mostly missing detections due to an unconfirmed bug or misconfiguration in MDE. This has been reported to Microsoft.
At this point, we have quite a substantial set of detections at FalconForce, on all kinds of techniques, including TGS requests by various attacker tools. Up until this research the detections we have built were based on the Active Directory logging, which is very reliable, when available.
Since Microsoft helped in porting the great Zeek project to Windows and embedded it into Defender for Endpoint (MDE), I was curious to find out whether there were detection opportunities in the Zeek telemetry.
Kerberoasting TL;DR
Attackers request service tickets for accounts tied to high-privileged services. They then extract and crack these tickets offline to retrieve the plaintext password. There are many implementations for this attack. Some of the most commonly reported tools for this are:
There are many more and several C2s also have their own implementation. From here on, this blog will focus on Rubeus. A similar approach can be taken for most other programs, but those are not covered here.
One of the first steps in our research when we start creating a detection, is looking at the source code, if available. Fortunately, in this case it is. One of the things that stood out to me while looking at the source code, is the selected time string in the screenshot below. There are certainly more useful bits in the source code, more on that later.
Part of the Rubeus source code on GitHub.
The ’till’ variable suggests it is the timestamp until when the requested ticket is valid, which is September 13th 2037 - not a Friday by the way 🙂 HarmJ0y, Rubeus’ author, even mentions in the comment that that relic came from Kekeo. A small search on GitHub showed several other projects using the same timestap.
GitHub search for the 20370913024805Z string.
Initial KQL exploration
As mentioned in the introduction, Zeek events are logged by MDE. Some of these Zeek events end up in the ‘Advanced Hunting’ section, which allows us to search for them. They live in the DeviceNetworkEvents and they are tagged with the NetworkSignatureInspected ActionType.
A basic search like the below one shows all Kerberos-related events:
DeviceNetworkEvents
| where ActionType == "NetworkSignatureInspected"
| where AdditionalFields contains "Kerberos"
| extend ADF=parse_json(AdditionalFields)
| extend SignatureName=tostring(ADF.SignatureName), SignatureMatchedContent=url_decode(tostring(ADF.SignatureMatchedContent)), SamplePacketContent=(tostring(ADF.SamplePacketContent))
| project-reorder SignatureName, SignatureMatchedContent, SamplePacketContent
Sample query results for the SamplePacketContent hovering.
Obviously, I was keen to find out what kind of data was available in the SamplePacketContent field. It looked URL-encoded, so I applied the url_decode function in KQL in an attempt to make it more readable.
SamplePacketContent after a KQL url_decode.
As you may have spotted, not much changed. After several attempts and going back to the documentation to understand what I was doing wrong, I could not figure it out. With some help from my colleague Henri, I later learned that it only works when the full string is only ASCII characters, otherwise it does nothing. I did not know that at the time and started poking around in the ever useful CyberChef tool.
CyberChef URLDecode on the SamplePacketContent.
After a URLDecode the data still was not fully readable, but my eye fell on the characters towards the end.. which showed the coveted ticket expiration timestamp! And after glancing at the encoded data at the top, I also spotted it in there.
SamplePacketContent snippet showing the ticket expiration timestamp.
Since the ticket expiration timestamp data was available in the raw event, I decided to extract it with a simple regex.
Ticket expiration timestamp extracted from SamplePacketContent.
Sweet! The next step was making this into a detection. Now you may think: what if an attacker modifies this possible indicator? A lazy or less sophisticated attacker will not do this, but one that takes it seriously will. So after some baselining, I found that normal TGS requests range between 10 hours and 30 days. In some cases up to 90 days. So I figured, let’s compare the requested time to the current time and if it exceeds that, alert.
DeviceNetworkEvents
| where ActionType == "NetworkSignatureInspected"
| where AdditionalFields contains "Kerberos"
| extend ADF=parse_json(AdditionalFields)
| extend SignatureName=tostring(ADF.SignatureName), SignatureMatchedContent=url_decode(tostring(ADF.SignatureMatchedContent)), SamplePacketContent=(tostring(ADF.SamplePacketContent))
| extend trimmed_str = replace_string(replace_string(SamplePacketContent, '["', ''), '"]', '')
| extend SamplePacketContent=url_decode(trimmed_str)
| extend TGSEndTime = extract(@"(\d{14}Z)", 0, SamplePacketContent)
| where isnotempty( TGSEndTime)
| extend ParsedTGSEndTime = todatetime(TGSEndTime)
| where ParsedTGSEndTime > now(+30d)
| project-reorder SignatureName, ParsedTGSEndTime
Example results for the query where the ticket expiration time is more than 30 days from the query moment.
This worked great in the lab, so now I wanted to run it in a big corporate production environment to make sure it can actually work for clients.
Result count for the same query in a large corporate environment.
How I felt after seeing this result.
That obviously did not work at all and turned out to be the wrong rabbit hole. Still somewhat dissatisfied, I wanted to understand some more about this timestamp. A substantial part of that enormous result count had the same timestamp and I was pretty sure the production environment was not so compromised that it happened thousands of times a day.
So, why is it set to 20370913024805Z?
On a 32-bit system, the maximum value for the Unix time is 2^{31} - 1 seconds, which translates to 03:14:07 UTC on January 19, 2038.
That is close to our timestamp, but not entirely the same. Which seems to indicate that this timestamp was intended as a sort of an infinite expiry time. Still, why have they decided on 128 days, 0 hours, 26 minutes, and 2 seconds?
I knew Steve Syfuhs has played a large role in the Kerberos implementation in Windows who mostly confirms my finding above as well.
By setting expiration dates to 2037, developers ensure they stay within the bounds of 32-bit time representation. Apparently, they decided to use this primarily for Linux compatibility.
After some more searching, I also found the Windows 2003 server source code which contains the same timestamp… It’s also hardcoded in some of the Windows flows, not only in attacker tools.
Sample of the Windows 2003 server source code.
Apparently, many people just used this timestamp, because why not ? 🙂 I have already observed some Windows 11 machines now using 99990913024805Z, which translates to 9999–09–13 02:48:05 UTC.
So, no detection….?
After sitting on that disappointing result for a brief moment, I was convinced there should be more to be gained from that data. One of the things we have successfully been using in our detections based on the Active Directory logs, is the ticket options which the client requests. So now the search started to find out whether the SamplePacketContent also contains that information.
Looking at what a Kerberos ticket contains, we can readily map some of the data within the raw packet information we have been looking at before. However, there is nothing that looks like those ticket options.
Fortunately, KRB service ticket options are well documented by Microsoft. When a service ticket is requested, EventID 4769 is generated on the contacted domain controller (detection opportunities here!). Since we are now interested in the client side, I leave this exercise to the reader. All possible options are captured in a bitmask, where each bit represents a flag or ticket option.
This becomes very relevant later in the blog post. Furthermore, Microsoft also has documented some more details about the TicketOptions field.
The most notable details are that a) the field is a HexInt32 type and b) the order of the bits is using the Most Significant Bit order. Always make sure to check for bit ordering when working with logs, as the implementation may differ across various events, which may lead to unexpected results. Trust me, I’ve been there. 😆
Since there was no obvious indication of the ticket options anywhere in the data, I spent some time trying to find it in various other ways. After being stuck for a bit, my colleague Henri swooped in to help out. He came up with the idea to convert the content to hex after the URLDecode, which we did in CyberChef.
Similar ticket options in a HEX representation of the SamplePacketContent compared to the Microsoft docs.
This worked great! We now confirmed that the data at least is in there. The next challenge was to extract it, since the url_decode function in KQL let us down. So, we needed to figure out a way to pry it from the raw data in the SamplePacketContent field.
Rabbit hole alert
Before we randomly start extracting data, we needed to understand a bit more about how Kerberos tickets are formatted, and how they are transferred over the wire. This would enable us to properly parse the data from that encoded packet data in a reliable way.
Some of the most valuable resources in this exercise were:
- The aptly named blog by Steve Syfus: Kerberos explained in a little too much detail.
- The Rust documentation is often a great resource when looking at Windows internals!
- And of course the RFC documentation for RFC4120.
These Kerberos ASN.1 packets can be encoded in two ways, Distinguished Encoding Rules (DER) or Basic Encoding Rules (BER). In this case, the implementation uses BER. ASN.1.BER encodes data in the Tag-Length-Value (TLV) order. If you are curious how that exactly works, I recommend reading this article. I created the below diagram to explain the structure.
Tag-Length-Value (TLV) order diagram.
In short, the order is 1 Byte as the tag or marker, then 1 byte to indicate the length of the value field and then the value of x bytes. The value field in its turn can contain another TLV set, which works in the same way as before, and can go on for several layers.
After some digging in the SamplePacketContent data, we managed to break it up a little to allow us to isolate the bytes we are looking for.
Split up bottom end of a SamplePacketContent item.
We learned several things from looking at multiple events. One of which is that the data is not always the same length, nor is it consistent in how complete it is. This also explains why that field was called SamplePacketContent 🙂 Some instances of this event will also contain the service the ticket is for (SMB, etc.), the requesting user and so on. The TicketOptions fortunately are always in there as far as we could determine, same as the, now less valuable, ticket end time (till).
We also learned the markers which indicate the type of data and the dataframe for the kdc options, is always 5 bytes long. This (almost) allows us to determine what to capture.
Dissected KDC options bytes from the SamplePacketContent field.
As we learned earlier, the actual content for the ticket options is always 32 bits, so the %00 is fixed. When we started dissecting the packet details, we saw that the other bytes can be urlEncoded or ASCII. Like in the example above, one of the bytes is an `@`, while the rest of the bytes are ‘%’ and a hex number. So we would need to account for that when parsing.
Regex is your best friend … sometimes
Next, we started writing a regular expression to grab that byte sequence from the SamplePacketContent field and we discovered new fun things to deal with.
Initial regex on a subset of the bytes from the SamplePacketContent field.
Sometimes the header is there twice and we are only interested in the second sequence. Additionally, we could not depend on a reliable number of characters after this header. We needed to be a bit more creative with our regex. Obviously, we need more regex 😀
We now know where our data ends. But also, there sometimes seemed to exist two options fields. We needed to get the correct (second) one. We also knew the markers for the realm, so we could use that, too.
Improved regex on a subset of the bytes from the SamplePacketContent field.
Look, how beautiful :D. Now we had this magical regex, we used some KQL to actually apply it to a query.
Every time I’m working on regex, this comic comes to mind.
Anyway, time for some KQL
Using the indexof() function, we can calculate the start position of our desired data. This function will return the numerical position in the string where our entry point is located.
| extend StartKdcOptions = indexof(trimmed_str, "%A0%07%03%05")+12
The length of the string representation of the TLV bytes before our actual data is 12 characters. We needed to add these 12 to our start index to get the exact position of our ticket options data.
Now we also needed to determine the end of our data position. Since we have seen that it does not have a fixed length due to the inconsistent encoding, we needed to work around that. The length of the realm is also not predictable, so we can’t find it based on a fixed string. Fortunately, we can find it using our beautiful regex with the indexof_regex() function.
| extend StartRealm = indexof_regex(trimmed_str, @"(?i)\%A2([^\%]|(\%[A-F0-9]{2}))\%1B([^\%]|(\%[A-F0-9]{2}))\w+\.\w+")
This regex tries to capture the different options for %A2..%1B, which was the marker for the realm bytes. The char in between is the length of the packet and can be a URL-encoded value or ASCII. The result of applying the regex within this function is the string position of when the realm bit starts, which should be right after our ticket options.
Next, we can grab the data we’re after, by getting whatever is in between the StartKdcOptions we calculated and before the StartRealm position, with the substring() function.
| extend StartKdcOptions = indexof(trimmed_str, "%A0%07%03%05")+12
| extend StartRealm = indexof_regex(trimmed_str, @"(?i)\%A2([^\%]|(\%[A-F0-9]{2}))\%1B([^\%]|(\%[A-F0-9]{2}))\w+\.\w+")
| where StartRealm > StartKdcOptions and StartRealm >= 0 and StartKdcOptions >= 0 // Filtering events without useful data
| extend krbflags = substring(trimmed_str, StartKdcOptions, StartRealm - StartKdcOptions)
Now we were almost there. We had too much data now, because the options flag is in there twice. So let’s address that.
Extracted Kerberos flags from the SamplePacketContent.
There is also a KQL function for that: iff()
Documentation for the iff() function.
| extend krbflags = iff(krbflags contains "%A0%07%03%05", substring(krbflags, indexof(krbflags, "%A0%07%03%05")+12), krbflags)
The iff checks if there is a second occurrence of the string and if so, it takes the index of the second occurrence in the overall string.
Finally, we got the byte sequence we were after. Well almost, since it was still mostly URL-encoded.
Encoded ticket options (krbflags) from the SamplePacketContent.
We needed to consider several things:
- Not all bytes are URL-encoded, so we need their ASCII hex value.
- KQL likes hex bytes to be prepended by 0x to properly convert it.
- We want binary values to calculate the bitmasks (yes, another rabbit hole). \0/
| mv-apply f = krbflags2 to typeof(string) on (
// If the current byte starts with %, it's already URL encoded, don't change. If not, get the ascii value with to_utf8(), take the first byte since it's always only 1 byte then convert the string to an int and then take the hex representation of it.
extend f = iff (f startswith "%", f, strcat("%", tohex(toint(to_utf8(f)[0]))))
// Replace % by 0x to make sure that the toint() function of KQL properly parses it as a hex value.
| extend f = replace_string(f, "%", " 0x")
// Join all the items back to an array.
| summarize f=make_list(f)
// Convert an array of hex bytes in the form [0xAA, 0xBB, 0xCC] into the number 0xAABBCC with some binary arithmetic.
| extend hexKRBflag = tohex(binary_shift_left(toint(f[0]), 4*8) + binary_shift_left(toint(f[1]), 3*8) + binary_shift_left(toint(f[2]), 2*8) + binary_shift_left(toint(f[3]), 1*8) + toint(f[4]))
)
Now, we have something familiar!
Decided and converted ticket options.
Burrowing even deeper into the rabbit hole with some bitwise operations
My colleague Henri has touched on this topic already in a blog on UnPACing the hash detections. Since it may not be top of mind, let’s do a brief refresher.
There are two important bitwise operations we need to understand to use these bitmasks in detections. These are AND and OR. Binary AND returns a 1 when both masks have a 1 in that position. Binary OR will return a 1 when at least 1 of the masks have a 1 in that position. This is illustrated in the diagram below.
We can use this to combine masks with the OR into 1 value or we can check whether the flags are set in a mask with the AND.
To check whether flag_X is set in a bitmask, we can do this by doing a binary AND of both the complete bitmask and the mask we are interested in (flag_X). If the result is equal to flag_X it is in there.
COMPLETE_BITMASK AND FLAG_X == FLAG_X
We can merge two masks into one by applying the OR. For example, first create the combined flag Forwardable_Renewable = “forwardable” OR “renewable”:
Merging the forwardable and renewable masks into one mask via binary OR.
Applying the above to our ticket options
Remember that in the beginning of this journey we already learned a very important bit of information from the Microsoft documentation. The full length of the bitmask for the ticket options is 32 bits AND the order is in the Most Significant Bit (MSB) notation. This basically means that the numbering starts from the left.
This allows us to calculate all the binary masks for each flag. We do not have to do this manually in KQL, but for the sake of understanding how it works, it is good to practice this occasionally.
Examples of bitmasks and their hex representation for some of the ticket options.
In KQL we can do the same with the binary_shift_left() function. This allows us to set a bit and move it x positions. Since we know we are working with a 32-bit mask and the first is reserved, we have 31 positions to move around in.
let forwardable = binary_shift_left(1, 30);
This statement basically sets bit 0 to 1 and moves it 30 positions to the left, illustrated in the image below.
Binary shift left of 30 positions in a 32-bit mask.
We can do this for other flags as well. For example, with the renewable flag, and merge them into one variable.
let forwardable = binary_shift_left(1, 30);
let renewable = binary_shift_left(1, 23);
let fwdAndRen = binary_or(forwardable, renewable);
If you are curious what that looks like in hex, we can print it, too.
Why go to all these lengths?
Good question. Attack tools often don’t fully mimic the common interactions that Windows does natively. This can be for many reasons:
- They need additional permissions.
- They follow the RFC where Microsoft has its own implementation.
- They only requested what they minimally need.
- They have deliberately created it this way as an IoC.
- Many other options.
When we revisited the Rubeus source code once more, we see that (by default) it requests canonicalize, forwardable, renewable and renewable_ok.
Ticket option request code in Rubeus.
However, when using a kirbi ticket which has been requested by Rubeus (or another tool) as well, it reuses the flags what were requested in that TGT request.
Ticket option request code in Rubeus when using a previously requested kirbi ticket.
Since both request events would incorporate at least the forwardable, renewable and renewable_ok flags, it’s more efficient to look for those three flags being set.
let forwardable = binary_shift_left(1, 30);
let renewable = binary_shift_left(1, 23);
let renewable_ok = binary_shift_left(1, 4);
let RubeusAskTGS = binary_or(forwardable, binary_or(renewable_ok, (binary_or(renewable, 0))));
// Rubeus with /askTGS without OPSEC.
[our super fancy regex magic here]
| extend ticketOptions0x=toint(strcat("0x",ticketOptions))
| where (binary_and(ticketOptions0x, RubeusAskTGS) == RubeusAskTGS
It may feel tedious having to create all these flag options, and reversing this when looking at logs might become tiresome. I also created a simple tool which allows you to analyze and or create these masks, including the applicable KQL, and even SPL code.
TGT / TGS flag calculator.
In the end, the detection looks something like this:
let forwardable = binary_shift_left(1, 30);
let renewable = binary_shift_left(1, 23);
let renewable_ok = binary_shift_left(1, 4);
let RubeusAskTGS = binary_or(forwardable, binary_or(renewable_ok, (binary_or(renewable, 0))));
DeviceNetworkEvents
| where ActionType == "NetworkSignatureInspected"
| where AdditionalFields contains "Kerberos_TGS_REQ"
| extend ADF=parse_json(AdditionalFields)
| extend SignatureName=tostring(ADF.SignatureName), SignatureMatchedContent=url_decode(tostring(ADF.SignatureMatchedContent)), SamplePacketContent=(tostring(ADF.SamplePacketContent))
| extend trimmed_str = replace_string(replace_string(SamplePacketContent, '["', ''), '"]', '')
| extend StartTLVFlags = indexof(trimmed_str, "%A0%07%03%05")+12
| extend StartTLVTag = indexof_regex(trimmed_str, @"(?i)\%A2([^\%]|(\%[A-F0-9]{2}))\%1B([^\%]|(\%[A-F0-9]{2}))\w+\.\w+")
| where StartTLVTag > StartTLVFlags and StartTLVTag >= 0 and StartTLVFlags >= 0
| extend krbflags = substring(trimmed_str, StartTLVFlags, StartTLVTag-StartTLVFlags)
| extend krbflags = iff(krbflags contains "%A0%07%03%05", substring(krbflags, indexof(krbflags, "%A0%07%03%05")+12), krbflags)
| extend krbflags2 = extract_all(@"(?i)(([^\%])|(\%[A-F0-9]{2}))", dynamic([1]), krbflags)
| project-away krbflags,trimmed_str, StartTLVFlags, StartTLVTag
| mv-apply f = krbflags2 to typeof(string) on (
extend f = iff (f startswith "%", f, strcat("%", tohex(toint(to_utf8(f)[0]))))
| extend f = replace_string(f, "%", " 0x")
| summarize f=make_list(f)
| extend ticketOptions = tohex(binary_shift_left(toint(f[0]), 4*8) + binary_shift_left(toint(f[1]), 3*8) + binary_shift_left(toint(f[2]), 2*8) + binary_shift_left(toint(f[3]), 1*8) + toint(f[4]))
)
| project-away krbflags2,f
| project-reorder Timestamp, DeviceId, DeviceName, ActionType, ticketOptions
| extend ticketOptions0x=toint(strcat("0x",ticketOptions))
| where (binary_and(ticketOptions0x, RubeusAskTGS) == RubeusAskTGS)
This results in a big number of false positives for events that also have the ‘forwarded’ flag set, which is not the case for the Rubeus events. We can filter these easily like this:
let forwarded = binary_shift_left(1, 29);
// ....rest of the logic....
| where not (binary_and(ticketOptions0x, forwarded) == forwarded)
So our final result looks like the query below:
let forwardable = binary_shift_left(1, 30);
let renewable = binary_shift_left(1, 23);
let renewable_ok = binary_shift_left(1, 4);
let forwarded = binary_shift_left(1, 29);
let RubeusAskTGS = binary_or(forwardable, binary_or(renewable_ok, (binary_or(renewable, 0))));
DeviceNetworkEvents
| where ActionType == "NetworkSignatureInspected"
| where AdditionalFields contains "Kerberos_TGS_REQ"
| extend ADF=parse_json(AdditionalFields)
| extend SignatureName=tostring(ADF.SignatureName), SignatureMatchedContent=url_decode(tostring(ADF.SignatureMatchedContent)), SamplePacketContent=(tostring(ADF.SamplePacketContent))
| extend trimmed_str = replace_string(replace_string(SamplePacketContent, '["', ''), '"]', '')
| extend StartTLVFlags = indexof(trimmed_str, "%A0%07%03%05")+12
| extend StartTLVTag = indexof_regex(trimmed_str, @"(?i)\%A2([^\%]|(\%[A-F0-9]{2}))\%1B([^\%]|(\%[A-F0-9]{2}))\w+\.\w+")
| where StartTLVTag > StartTLVFlags and StartTLVTag >= 0 and StartTLVFlags >= 0
| extend krbflags = substring(trimmed_str, StartTLVFlags, StartTLVTag-StartTLVFlags)
| extend krbflags = iff(krbflags contains "%A0%07%03%05", substring(krbflags, indexof(krbflags, "%A0%07%03%05")+12), krbflags)
| extend krbflags2 = extract_all(@"(?i)(([^\%])|(\%[A-F0-9]{2}))", dynamic([1]), krbflags)
| project-away krbflags,trimmed_str, StartTLVFlags, StartTLVTag
| mv-apply f = krbflags2 to typeof(string) on (
extend f = iff (f startswith "%", f, strcat("%", tohex(toint(to_utf8(f)[0]))))
| extend f = replace_string(f, "%", " 0x")
| summarize f=make_list(f)
| extend ticketOptions = tohex(binary_shift_left(toint(f[0]), 4*8) + binary_shift_left(toint(f[1]), 3*8) + binary_shift_left(toint(f[2]), 2*8) + binary_shift_left(toint(f[3]), 1*8) + toint(f[4]))
)
| project-away krbflags2,f
| project-reorder Timestamp, DeviceId, DeviceName, ActionType, ticketOptions
| extend ticketOptions0x=toint(strcat("0x",ticketOptions))
| where (binary_and(ticketOptions0x, RubeusAskTGS) == RubeusAskTGS)
| where not (binary_and(ticketOptions0x, forwarded) == forwarded)
There are several caveats though, one of which is that Rubeus also has an opsec flag, which, somehow, we don’t see many operators using (no RTFM?). This essentially mimics a normal request and will be avoiding our detection.
Small part of the opsec flags in the Rubeus source code.
The other one is that the shared detection now only detects some implementations; there are other options to be analyzed for Rubeus as well. Additionally, the same research as above needs to be applied to all other tools and implementations which have an ability to work with Kerberos service tickets and will look different, as we learned ourselves in prior research.
End note
Regular Zeek users would argue that a Zeek script can also log these ticket options directly, which would be very true and WAAAAY easier to use. Sadly, MDE does not expose this ability, nor this information. I did find out there is information passed to the EDR component, which we do not have access to. Based on our research into the configuration of MDE we confirmed this telemetry includes some of the flags, but not all of them; so it would not be as useful in its current state.
Unfortunately, the approach in this blog is not the most reliable due to the truncation of the packets, it’s called sample for a reason. We have seen cases where the ticket options data was also truncated, so that may cause some blindspots.
The intention of the blog is to share the process, the failures and to spark some ideas to use your data to its full extent.
The sad end note
As mentioned in the sad note in the beginning, the detection shared above currently does not yield results in almost any occurrence. Which makes it somewhat of an underwhelming conclusion of this write-up. However, keep in mind that all the techniques used in this research apply to many other events and are therefore not wasted.
If you’re still here and reading, you may be interested in some details of what I found out while trying to figure out why I didn’t get events anymore. As of the time of writing, this is still under investigation, so there is no definitive answer, yet. The replays below have been tested several times over a period of several weeks to assure it was not a one-time mistake.
In the screenshot below we can see the following: there are two TGS requests, to two different servers. There is some time in between requests to avoid confusion and to assure a high likelihood of at least a different source port between the two network requests. The query has been run almost an hour after the requests and searches for all TGS requests in the past 24 hours. The query only show results for events that happened hours before the TGS requests were made.
Search in KQL for the TGS requests made with Rubeus, through a Mythic agent.
In earlier blogs, I have shown that the MDE configuration contains a capping strategy for many event types. Since I didn’t get any events of my attacks, I was curious to find out whether capping was the reason for these events not showing up.
Snippet of the MDE configuration regarding the capping of TGS request events.
In the configuration for this event type, we can see that there is a SameDataFirstSeen subset that only logs 1 event per 24 hours. This capping is applied to events that have identical values in the fields in that array.
Since I ran two separate requests, I would expect them to have at least a different source port, but also the destination for the second request, which goes to a different server, should be different as well.
The FirstSeenBytes was unfamiliar to me, so I had to look up what that was about exactly.
Snippet of the MDE configuration containing the bytes sets logged, showing the FirstSeenBytes settings.
So, the FirstSeenBytes seems to take the first 100 bytes of the packet. Since I don’t have access to the data that is processed by the engine, I have no way of telling whether there is distinct enough data in there to make my two requests have different FirstSeenBytes. As the start of most of the TGS packets will be the same, apart from a destination port and IP, it may be possible that this is a misconfiguration. Again, this depends on where the engine starts processing these TGS request packets. I’ve shared my findings with the developers.
Again, while this has been an attempt at an alternative detection, a way more reliable approach for detections regarding these types of malicious requests is basing them off Active Directory events (4768, 4769). These are uncapped, and unavailable to tamper with by an attacker. The best they can do, is trying to look as much as a normal event as possible, if that still suits their requirements.
Detections for the same request as above, based on Active Directory events.
Knowledge center
Other articles
Azure DevOops 0x01 – It is not my machines, it is your code!
[dsm_breadcrumbs show_home_icon="off" separator_icon="K||divi||400" admin_label="Supreme Breadcrumbs" _builder_version="4.18.0" _module_preset="default" items_font="||||||||" items_text_color="rgba(255,255,255,0.6)" custom_css_main_element="color:...
Automating enumeration of missing reply URLs in Azure multitenant apps
[dsm_breadcrumbs show_home_icon="off" separator_icon="K||divi||400" admin_label="Supreme Breadcrumbs" _builder_version="4.18.0" _module_preset="default" items_font="||||||||" items_text_color="rgba(255,255,255,0.6)" custom_css_main_element="color:...
FalconFriday — Detecting MMC abuse using GrimResource with MDE— 0xFF24
[dsm_breadcrumbs show_home_icon="off" separator_icon="K||divi||400" admin_label="Supreme Breadcrumbs" _builder_version="4.18.0" _module_preset="default" items_font="||||||||" items_text_color="rgba(255,255,255,0.6)" custom_css_main_element="color:...
Together. Secure. Today.
Stay in the loop and sign up to our newsletter
FalconForce realizes ambitions by working closely with its customers in a methodical manner, improving their security in the digital domain.
Energieweg 3
3542 DZ Utrecht
The Netherlands
FalconForce B.V.
[email protected]
(+31) 85 044 93 34
KVK 76682307
BTW NL860745314B01