Vulnerability deep-dive with KQL

Vulnerability deep-dive with KQL

In some cases, Microsoft Defender will report vulnerable software in your organization but the device(s) are patched - so why is it reporting as vulnerable?

Certain software gets installed in user context or forget to remove older installations - such as Microsoft Teams.

Microsoft Teams, Google Chrome and others updates themselves when running, but if it never runs it also does not update (unless forced through some kind of software delivery system).

If a device has been used by other users before and they have this software product installed, it will just lay there dormant on disk but report as vulnerable.

You can also run into scenarios where a device reports as vulnerable because Microsoft Defender finds a registry key that indicates the software is installed - even if there are no files. Removing this key will remove the vulnerable flag for the device.

All of these things are not very visible (or at all) in the Defender portal so we dig into the actual data instead to find out the wherefores and whatnots.

Below is the query in full if you just want to run it - to learn more about how it works you can skip to the breakdown.

This is prepared to be saved as a re-usable function in Microsoft Defender Advanced Hunting, but the non-function variant can be seen at the bottom of this article.

Run it by replacing softwareName with your hearts desire - such as "teams".
To retrieve all software you can query for, use this query: DeviceTvmSoftwareVulnerabilities | distinct SoftwareName

let vulnerabilitySearch = (software : string){
DeviceTvmSoftwareVulnerabilities
| where SoftwareName == software
| project DeviceId, DeviceName, SoftwareName, VulnVersion=SoftwareVersion
| join kind=rightouter (
    DeviceTvmSoftwareInventory
    | where SoftwareName == software
    | project DeviceName, SoftwareVersion)
    on DeviceName
| join kind=inner (DeviceTvmSoftwareEvidenceBeta
    | where SoftwareName == software
    | extend InstallLocations = bag_pack("Version", SoftwareVersion, "Paths", DiskPaths, "RegistryPaths", RegistryPaths)
    | project DeviceId, InstallLocations)
    on DeviceId
| summarize
    InstallLocations=make_set(InstallLocations),
    VulnerableVersions=make_set(VulnVersion),
    AllVersions=make_set(SoftwareVersion)
    by DeviceName
| extend SafeInstalls = set_difference(AllVersions, VulnerableVersions)
| where not(isempty(DeviceName))
| project DeviceName, InstallLocations, VulnerableVersions, SafeInstalls
};
vulnerabilitySearch(softwareName)

To save this as a function, click Save -> Save as function.

Set the configuration like this:

Once saved, you can run the query easily (and use it as part of other queries) by just specifying VulnerabilitySearch("teams")

Running the function will provide us with a report looking like this:

Here we see the device and all installed locations for the requested software - we also get which versions are vulnerable according to DeviceTvmSoftwareVulnerabilities and we get the safe versions (by way of filtering for versions not present in the vulnerable version list).

If a device reports values in the SafeInstalls column, the device has been patched but something is blocking it from being reported as safe by Microsoft Defender - in that case, look at the VulnerableVersions and match with data in InstallLocations.

Breaking down the query

Lets break this down to understand what we are doing in the query.

First, we want to retrieve all the detected vulnerable versions of the given software by querying the DeviceTvmSoftwareVulnerabilities table. This table gets constantly updated with the last known status of each device. If a device gets patched, the entry disappears from this table.

let software="teams";
DeviceTvmSoftwareVulnerabilities
| where SoftwareName == software
| project DeviceId, DeviceName, SoftwareName, VulnVersion=SoftwareVersion

We rename the column SoftwareVersion to VulnVersion to easier distinguish from safe versions when we are expanding the query later.

Next, we also want to include all the detected versions installed on each device - the DeviceTvmSoftwareInventory table keeps track of this; regardless of the software being vulnerable or not.

We join this together with the vulnerable versions using the DeviceName as our link - and we will specify that the join kind will be rightouter because we want all matching rows from DeviceTvmSoftwareVulnerabilities and all rows (regardless of matches) in DeviceTvmSoftwareInventory to ensure that we get all installed versions of the software together with any vulnerable versions:

let software="teams";
DeviceTvmSoftwareVulnerabilities
| where SoftwareName == software
| project DeviceId, DeviceName, SoftwareName, VulnVersion=SoftwareVersion
| join kind=rightouter (
    DeviceTvmSoftwareInventory
    | where SoftwareName == software
    | project DeviceName, SoftwareVersion)
    on DeviceName

To help us track down where the software is installed, we will include a join with DeviceTvmSoftwareEvidenceBeta which is a table Microsoft Defender uses to store evidence of why it believes a software product is installed. It does this by looking for disk paths or registry keys as indicators.

let software="teams";
DeviceTvmSoftwareVulnerabilities
| where SoftwareName == software
| project DeviceId, DeviceName, SoftwareName, VulnVersion=SoftwareVersion
| join kind=rightouter (
    DeviceTvmSoftwareInventory
    | where SoftwareName == software
    | project DeviceName, SoftwareVersion)
    on DeviceName
| join kind=inner (DeviceTvmSoftwareEvidenceBeta
    | where SoftwareName == software
    | extend InstallLocations = bag_pack("Version", SoftwareVersion, "Paths", DiskPaths, "RegistryPaths", RegistryPaths)
    | project DeviceId, InstallLocations)
    on DeviceId

We pack the data we want into a new column InstallLocations to avoid too much noise and make the query easier. Here we include the RegistryPaths column because quite often we can see a registry key such as HKEY_USERS\Frode\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\Teams without a corresponding DiskPath entry which indicates the software is not actually installed - Defender only thinks so because it found a registry entry for it.

We can now put it all together and summarize our report - we will use KQL function set_difference to find all versions from the software inventory that are not present in the vulnerability-table and assume that these are the patched and safe versions.

let software="teams";
DeviceTvmSoftwareVulnerabilities
| where SoftwareName == software
| project DeviceId, DeviceName, SoftwareName, VulnVersion=SoftwareVersion
| join kind=rightouter (
    DeviceTvmSoftwareInventory
    | where SoftwareName == software
    | project DeviceName, SoftwareVersion)
    on DeviceName
| join kind=inner (DeviceTvmSoftwareEvidenceBeta
    | where SoftwareName == software
    | extend InstallLocations = bag_pack("Version", SoftwareVersion, "Paths", DiskPaths, "RegistryPaths", RegistryPaths)
    | project DeviceId, InstallLocations)
    on DeviceId
| summarize
    InstallLocations=make_set(InstallLocations),
    VulnerableVersions=make_set(VulnVersion),
    AllVersions=make_set(SoftwareVersion)
    by DeviceName
| extend SafeInstalls = set_difference(AllVersions, VulnerableVersions)
| where not(isempty(DeviceName))
| project DeviceName, InstallLocations, VulnerableVersions, SafeInstalls

Hopefully this will help you get a deeper understanding of the vulnerabilities in your organization and a starting point how to get control of the more mysterious ones.