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.