Monitoring Zoom with Azure Sentinel
Published Apr 28 2020 09:48 AM 22.7K Views

In a recent blog we talked about the explosion in usage we had seen with Microsoft Teams as the world has moved to working from home. However, Microsoft Teams is not the only application to see such as surge, Zoom is another remote productivity tool that has seen a massive increase in users, with more than 200 million daily meeting participants being reported in March. Just as Security Operation Centers (SOCs) need to monitor Microsoft Teams activity they also need to be able to secure and monitor other productivity applications such as Zoom.

One of the great features of Azure Sentinel is its ability to ingest and analyze data from any source not just from Microsoft products. In this blog I will show you how you can collect logs from Zoom, ingest them into Azure Sentinel, and how a SOC team can start to hunt in the logs to find potentially malicious activity.


Zoom’s logging capabilities

Zoom has a robust range of logging available to users via the GUI such as the Operation Log, Signin events ,Call Logs, and SAML response events. Zoom also exposes these events via its API, and allows you to create webhooks to subscribe to the various events that Zoom generates. When these events occur the webhook sends these events to the remote collection endpoint you specify. What events are generated depends on what subscription level you have and what features you are using, but even free account users can access events such as logins and meeting starts via this .


Collecting Zoom logs with Azure Sentinel

We are going to combine Zoom's webhook capability with Azure Functions and Azure Sentinel to collect data in a flow that looks like this:

Connector Architecture DiagramConnector Architecture Diagram


Before configuring our Zoom webhook we first need to set up an endpoint that Zoom can send events to. This is where we are going to use Azure Functions. Our Function will listen for POST events and when received will write the body of the event into our Azure Sentinel’s Log Analytics Workspace.  We have created a template PowerShell based Azure Function for you to use and included ARM templates to make deployment quick and simple.  We won’t cover the details of deploying this Function here but there are instructions available on GitHub, and additional documentation available at Microsoft Docs.


Once our Azure Function App is up and running we need to set up a Zoom API app to send the Zoom logs to our Azure Function. To do this log into with your Zoom account, this should be a Zoom admin in order to collect all events under the account. Once signed in follow these steps:

  1. Select ‘Develop’ in the top right-hand corner and click ‘Build App’.
  2. Select ‘Webhook Only’ as your app type.
  3. Give your app a name.
  4. Fill out the required Basic Information and click continue.
  5. Under the Feature Tab enable the ‘Event Subscriptions’ toggle and click ‘Add new event subscription’.
  6. Set a subscription name and in the Event notification endpoint URL enter your Function App URL. This will be in the format of https://<FunctionAppName><FunctionName>. You can find this your app URL in the Azure Portal.
  7. Click ‘Add Events’ and select the events you want to receive in Azure Sentinel. Then click done.
  8. Copy your feature Verification token and save it.
  9. Click ‘Save’ and ‘Continue’.

 Creating your Zoom appCreating your Zoom app


The final step is to update our Function App with the Zoom app verification code. We can do this by navigating to our Function App in the Azure Portal, select Configuration and then update the value for the ZoomVerification parameter with the value copied from the Zoom app portal. By default, this Function App parameter is set to None, if this value is left then the verification step is skipped.

If everything is configured correctly you should start seeing events appear in your Azure Sentinel workspace as soon as they start to be generated. If you are having issues, there are several options to monitor and debug your Function App.


Parsing Logs

As we did with our Team’s logs we are going to create a parser to normalize and tidy our Zoom events before we start hunting in them.  Zoom events have subtly different schema depending on the feature they are generated by so our parser needs to be quite complex to deal with each of these and project a unified schema:


let chatbot_events = dynamic(['bot_notification','interactive_message_editable','interactive_message_fields_editable','interactive_message_select', 'interactive_message_actions']); 
let account_events = dynamic(['account.created','account.updated','account_disassociated']); 
let chat_events = dynamic(['chat_message.sent','chat_message.updated','chat_message.updated']); 
let channel_events = dynamic(["chat_channel.created", "chat_channel.updated","chat_channel.deleted","chat_channel.member_invited","chat_channel.member_joined","chat_channel.member_left"]); 
let meeting_events = dynamic(["meeting.alert","meeting.created","meeting.updated","meeting.deleted","meeting.started","meeting.ended","meeting.registration_created","meeting.registration_approved","meeting.registration_cancelled","meeting.sharing_started","meeting.sharing_ended","meeting.participant_jbh_waiting","meeting.participant_jbh_joined","meeting.participant_joined","meeting.participant_left"]); 
let recording_events = dynamic(["recording.started","recording.paused","recording.resumed","recording.stopped","recording.completed","recording.renamed","recording.trashed","recording.deleted","recording.recovered","recording.transcript_completed","recording.registration_created","recording.registration_approved","recording.registration_denied"]); 
let user_events = dynamic(["user.created","user.invitation_accepted","user.updated","user.settings_updated","user.deactivated","user.activated","user.disassociated","user.deleted","user.personal_notes_updated"]); 
let signin_events = dynamic(["user.signed_in","user.signed_out"]); 
let webinar_events = dynamic(["webinar.created", "webinar.updated","webinar.started","webinar.ended","webinar.alert","webinar.sharing_started","webinar.sharing_ended","webinar.registration_created","webinar.registration_approved","webinar.registration_denied", "webinar.registration_cancelled", "webinar.participant_joined", "webinar.participant_left"]); 
let room_events = dynamic(["zoomroom.alert", "zoomroom.delayed_alert"]); 
| extend Event = event_s,  
    EventTime = iif(event_s in (chatbot_events), columnifexists('payload_timestamp_d', ""), columnifexists('payload_object_data_time_d', tostring(TimeGenerated))), 
    User = case(event_s in (account_events), columnifexists("payload_operator_s",""), 
            event_s in (chatbot_events), columnifexists("payload_userJid_s",""), 
            event_s in (user_events), columnifexists("payload_operator_s", ""), 
            event_s in (signin_events), columnifexists("payload_object_email_s", ""), 
            event_s == "user.presence_status_updated",columnifexists("payload_object_email_s",""), 
            event_s in (recording_events), columnifexists("payload_object_registrant_email_s", "") ,columnifexists("payload_operator_s","" 
    UserId = case(event_s in (account_events), columnifexists("payload_operator_id_s",""), 
             event_s in (chat_events) or event_s in (channel_events), columnifexists("payload_operator_id_s",""), 
             event_s in (webinar_events), columnifexists("payload_object_registrant_id_s",""), 
             event_s in (chatbot_events), columnifexists("payload_userId_s",""),  
             event_s in (signin_events) or event_s == "user.presence_status_updated", columnifexists("payload_object_id_s", ""), 
             event_s in (meeting_events), columnifexists("payload_operator_id_s", ""), 
| extend MeetingEvents = iif(event_s in (meeting_events), pack("MeetingName", columnifexists("payload_object_topic_s",""), "MeetingId", columnifexists("payload_object_uuid_s",""), "Timezone", columnifexists("payload_object_timezone_s",""), "Start",columnifexists("payload_object_start_time_t","") ,"End", columnifexists("payload_object_end_time_t",""), "Participant", columnifexists("payload_object_participant_user_name_s","")) , "") 
| extend WebinarEvents = iif(event_s in (webinar_events), pack("WebinarName", columnifexists("payload_object_topic_s",""), "WebinarId", columnifexists("payload_object_uuid_s",""), "Timezone", columnifexists("payload_object_timezone_s",""), "Start", columnifexists("payload_object_start_time_t","") ,"End", columnifexists("payload_object_end_time_t",""), "Participant", columnifexists("payload_object_participant_user_name_s","")), "") 
| extend ChannelEvents = iif(event_s in (channel_events), pack("Channel", columnifexists("payload_object_name_s",""), "ChannelId", columnifexists("payload_object_id_s",""), "Member", columnifexists("payload_object_members_display_name_s","")) , "") 
| extend ChatEvents = iif(event_s in (chat_events), pack("Channel", columnifexists("payload_object_channel_name_s",""), "ChannelId", columnifexists("payload_object_channel_id_s",""),"Type", columnifexists("payload_object_type_s",""), "Message", columnifexists("payload_object_message_s","")),"") 
| extend UserEvents = iif(event_s in (user_events), pack("UserId", columnifexists("payload_object_id",""), "UserEmail", columnifexists("payload_object_email",""),"UserFirstName", columnifexists("payload_object_first_name_s", ""), "UserLastName", columnifexists("payload_object_last_name_s",""), "UserType", columnifexists("payload_object_type_s", "")), "") 
| extend RecordingEvents = iif(event_s in (recording_events), pack("RecordingName",columnifexists("payload_object_topic_s",""),"RecordingURL",columnifexists("payload_object_share_url_s",""),"RecordingSize",columnifexists("payload_object_total_size_d","")),"") 
| extend RoomEvents = iif(event_s in (room_events), pack("RoomName", columnifexists("payload_objsct_room_name_s", ""), "RoomEmail", columnifexists("payload_object_email_s", ""), "RoomEvent", columnifexists("payload_object_issue_s", ""), "AlertType", columnifexists("payload_object_alert_type_d", ""), "AlertKind", columnifexists("payload_object_alert_kind_d","")), "") 
| project-reorder TimeGenerated, Event, EventTime, User, UserId, MeetingEvents, WebinarEvents, ChannelEvents,UserEvents, RecordingEvents, RoomEvents, * 



The parser generates grouped objects of data relevant to each category of event that Zoom generates, for example the column RecordingEvents is populated with data related to a meeting or webinar recording. For each event, one (or potentially none) of these columns will be populated, and the others will be blank. For the purposes of the queries below we presume the parser is saved as a function with the alias ‘ZoomLogs’. Details on configuring and using a Function as a parser can be found in this blog. 


Monitoring Zoom Activity

Once we have parsed the data there are a number of uses cases, we can build for threat detection. We have broken these down into detections and hunting queries, detections are better suited to being deployed as Azure Sentinel Analytics as they are less prone to false positives, whereas hunting queries are designed to be used as part of a threat hunt and could include events that are not malicious. We recommend you review each of these queries before using them to ensure they are relevant and applicable for your organization's threat model.



End to End Encryption Disabled

Mitre ATT&CK Technique T1040

Zoom meetings are protected by end to end encryption[1], however, this can be disabled at a Zoom account[2] level, attackers with access to modify account settings may disable this encryption in order to intercept meeting and webinar communications.


  let timeframe = 1d;
  | where TimeGenerated >= ago(timeframe)
  | where Event =~ "account.settings_updated"  
  | extend OldE2ESetting = columnifexists("payload_object_settings_in_meeting_e2e_encryption_b", "")  
  | extend NewE2ESetting = columnifexists("payload_old_object_settings_in_meeting_e2e_encryption_b", "")  
  | where OldE2ESetting =~ 'false' and NewE2ESetting =~ 'true'
  | extend timestamp = TimeGenerated, AccountCustomEntity = User



External  Users Access Enabled

Mitre ATT&CK Technique T1098

Zoom lets you restrict users who can join a meeting by making all users log in to an account before joining, and furthermore by restricting what domains a user can log in using. An attacker could manipulate these settings in order to gain persistent access to meetings without a valid organization account.


  let timeframe = 1d;
  | where TimeGenerated >= ago(timeframe)
  | where Event =~ "account.settings_updated" 
  | extend EnforceLogin = columnifexists("payload_object_settings_schedule_meeting_enfore_login_b", "") 
  | extend EnforceLoginDomain = columnifexists("payload_object_settings_schedule_meeting_enfore_login_b", "") 
  | extend GuestAlerts = columnifexists("payload_object_settings_in_meeting_alert_guest_join_b", "") 
  | where EnforceLogin == 'false' or EnforceLoginDomain == 'false' or GuestAlerts == 'false' 
  | extend SettingChanged = case(EnforceLogin == 'false' and EnforceLoginDomain == 'false' and GuestAlerts == 'false', "All settings changed", 
                              EnforceLogin == 'false' and EnforceLoginDomain == 'false', "Enforced Logons and Restricted Domains Changed", 
                              EnforceLoginDomain == 'false' and GuestAlerts == 'false', "Enforced Domains Changed", 
                              EnforceLoginDomain == 'false', "Enfored Domains Changed", 
                              GuestAlerts == 'false', "Guest Join Alerts Changed", 
                              EnforceLogin == 'false', "Enforced Logins Changed", 
                              "No Changes")
  | extend timestamp = TimeGenerated, AccountCustomEntity = User



Suspicious Link Sharing Pattern

Mitre ATT&CK Technique T1192

An attacker with access to Zoom environments could use the access to share and spread malicious links across an organization. This detection looks for a link that is shared across multiple Zoom chat channels by a single user, within a short space of time. This detection is prone to false positives so you may wish to deploy it as a hunting query initially.

  let threshold = 3; 
  let lookback = 1d; 
  | where TimeGenerated >= ago(lookback) 
  | where Event =~ "chat_message.sent" 
  | extend Channel = tostring(parse_json(ChatEvents).Channel)  
  | extend Message = tostring(parse_json(ChatEvents).Message) 
  | where Message matches regex "http(s?):\\/\\/" 
  | summarize Channels = makeset(Channel), StartTime = min(TimeGenerated), EndTime = max(TimeGenerated) by Message, User, UserId
  | extend ChannelCount = arraylength(Channels) 
  | where ChannelCount > threshold
  | extend timestamp = StartTime, AccountCustomEntity = User



User Joining Meeting from Different Time Zone

Mitre ATT&CK Technique T1078

Some organizations that have adopted Zoom, such as the UK Cabinet Office[3], are based solely within a single time zone (or country) and given the current situation they can expect all users to remain in that time zone. If this is the case for your organization, you can monitor for users joining a meeting from a time zone that is different from the one the meeting was created in to help identify potentially suspicious users. The query also includes the ability to authorize known good time zones should you expect users to be joining from multiple locations.


  let schedule_lookback = 14d; 
  let join_lookback = 1d; 
  // If you want to authorize specific timezones include them in a list here
  let tz_whitelist = dynamic([]);
  let meetings = ( 
  | where TimeGenerated >= ago(schedule_lookback) 
  | where Event =~ "meeting.created" 
  | extend MeetingId = tostring(parse_json(MeetingEvents).MeetingId)  
  | extend SchedTimezone = tostring(parse_json(MeetingEvents).Timezone)); 
  | where TimeGenerated >=W ago(join_lookback) 
  | where Event =~ "meeting.participant_joined" 
  | extend JoinedTimeZone = tostring(parse_json(MeetingEvents).Timezone) 
  | extend MeetingName = tostring(parse_json(MeetingEvents).MeetingName) 
  | extend MeetingId = tostring(parse_json(MeetingEvents).MeetingId) 
  | where JoinedTimeZone !in (tz_whitelist)
  | join (meetings) on MeetingId 
  | where SchedTimezone != JoinedTimeZone 
  | project TimeGenerated, MeetingName, JoiningUser=payload_object_participant_user_name_s, JoinedTimeZone, SchedTimezone, MeetingScheduler=User1 
  | extend timestamp = TimeGenerated, AccountCustomEntity = JoiningUser



Hunting Queries


Potentially Compromised Room System

Mitre ATT&CK Technique T1109

MSTIC has previously observed threat actors compromising Internet of Things (IoT) systems, including VOIP devices, in order to gain a foothold in a network. One indicator of compromise of IoT devices can be increase CPU load as typically such devices have CPU specced to the to the singular role required of them. Therefore, attackers compromising these devices and executing code can generate increased CPU load. Zoom Room Systems produce alerts for various events, including high CPU load and we can use this to look for anomalous CPU load events.


  let hunt_time = 14d; 
  | where TimeGenerated >= ago(hunt_time) 
  | where Event =~ "zoomroom.alert" 
  | extend AlertType = toint(parse_json(RoomEvents).AlertType), AlertKind = toint(parse_json(RoomEvents).AlertKind) 
  | extend RoomName = payload_object_room_name_s, User = payload_object_email_s
  | where AlertType == 1 and AlertKind == 1 
  | extend timestamp = TimeGenerated, AccountCustomEntity = User
  // Uncomment the lines below to analyse event over time
  //| summarize count() by bin(TimeGenerated, 1h), RoomName
  //| render timechart



If we have a large number of such events, we can easily chart this on the TimeGenerated axis to look for patterns.


Multiple Recording or Webinar Registration Denies

Mitre ATT& CK Technique T1078

Zoom events such as Webinars and Recordings can be opened to the public with a manual user approval step for registrants. An external attacker wanting to gain access to an organization's information could attempt to register for webinars or recordings they should not have access to. Looking for multiple registration rejections from the same user account followed by an approval can allow us to identify potentially malicious actors.


  let hunt_time = 14d; 
  let threshold = 2; 
  let failed_users = (
  | where TimeGenerated >= ago(hunt_time) 
  | where Event =~ "webinar.registration_denied" or Event =~ "recording.registration_denied" 
  | extend RegisteringUser = columnifexists('payload_object_registrant_email_s', payload_object_registrant_email_s)
  | extend ItemId = columnifexists('tostring(parse_json(WebinarEvents).WebinarId)',payload_object_uuid_s)
  | summarize dcount(ItemId) by RegisteringUser
  | where dcount_ItemId > threshold
  | project RegisteringUser);
  | where TimeGenerated >= ago(hunt_time) 
  | where Event =~ "webinar.registration_approved" or Event =~ "recording.registration_approved" 
  | extend RegisteringUser = columnifexists('payload_object_registrant_email_s', columnifexists('payload_object_registrant_email_s', "")) 
  | extend ItemId = columnifexists('tostring(parse_json(WebinarEvents).WebinarId)',columnifexists('payload_object_uuid_s', ""))
  | extend EventName = columnifexists('tostring(parse_json(WebinarEvents).WebinarName)',columnifexists('payload_object_topic_s', ""))
  | extend EventHost = columnifexists('payload_object_host_id',"")
  | extend EventStart = columnifexists('tostring(parse_json(WebinarEvents).Start)',columnifexists('payload_object_start_time_s' ,""))
  | where RegisteringUser !in (failed_users)
  | project TimeGenerated, RegisteringUser, EventName, ItemId, EventHost, EventStart
  | extend timestamp = TimeGenerated, AccountCustomEntity = RegisteringUser



New Domain Access Added

Mitre ATT&CK Technique T1098

Zoom allows you to filter the domains from which users joining meetings can come from. An attacker may attempt to add to this list in order to enable access. This hunting query looks at recently added domains so that any new additions can be audited.


  let hunt_time = 14d; 
  | where TimeGenerated >= ago(hunt_time)
  | where Event =~ "account.settings_updated"
  | extend NewDomains = columnifexists("payload_object_enforce_logon_domains", "")
  | where isnotempty(NewDomains)
  | project TimeGenerated, Event, User, NewDomains
  | extend timestamp = TimeGenerated, AccountCustomEntity = User



User Joining Meeting from a New Time Zone

Mitre ATT&CK Technique T1078

In addition to looking for accounts joining meetings from a time zone that is different from the meeting organized time zone we can look for users joining for time zones we have not observed in the last 14 days. In the current situation it’s unlikely users are going to be moving location much so the list of time zones seen should be relatively static.


  let hunt_time = 1d;
  let lookback_time = 14d;
  let previous_tz = (
    | where TimeGenerated >= ago(lookback_time)
    | where Event =~ "meeting.participant_joined"
    | extend TimeZone = columnifexists('payload_object_timezone_s', "")
    | summarize by TimeZone
  | where TimeGenerated >= ago(hunt_time)
  | where Event =~ "meeting.participant_joined"
  | extend TimeZone = columnifexists('payload_object_timezone_s', "")
  | where isnotempty(TimeZone) and TimeZone in (previous_tz)
  | extend timestamp = TimeGenerated, AccountCustomEntity = User




This is our first look at collecting and analyzing Zoom data with Azure Sentinel. Whilst the queries included here are a starting points for detection and hunting, we are sure that are plenty more ideas out there and we would love to see the community sharing these via GitHub.




[1] Zoom defines this differently to other vendors, refer to Zoom’s whitepaper on encryption for more details

[2] A Zoom account refers to the organization level rather than a user level. This is broadly analogous with a tenant within Microsoft services.




Version history
Last update:
‎Apr 28 2020 09:48 AM
Updated by: