A fresh look at user enumeration in Microsoft Teams

Introduction

Real-world attack simulations usually start with a broad reconnaissance phase during which information about the target organization are collected. This involves collecting names and email addresses of employees in order to prepare targeted attacks that are executed in later stages.

User enumeration in Azure Active Directory environments is proven to be possible in many configurations and very effective as covered by a series of tools and posts [1][2][3].

A common way to enumerate users is to use the GetCredentialType endpoint on https://login.microsoftonline.com/common/GetCredentialType. The success of this method depends on certain features, for example whether or not "Seamless SSO" is enabled (see [4]). For more flexibility it's useful to have some backup strategies available.

The technique to enumerate user details and presence information via Microsoft Teams is not new and was described in a blog post by immunit.ch and their tool "TeamsUserEnum" [5][6].

This blog post adds more information related to user enumeration via Teams and covers different endpoints used by different account types. Last but not least, the new python-based tool TeamsEnum [7] is introduced that is inspired by "TeamsUserEnum".

This blog post clarifies the following key elements:

  • Personal accounts use the endpoint teams.live.com, while organizational accounts use teams.microsoft.com
  • Despite all visibility settings that can be configured in the Teams Admin Center, the existence of a teams-enrolled corporate user can always be proven, when searching with a corporate account
  • Personal accounts can't obtain presence information of corporate users unless both users did initiate a conversation in the past
  • Privacy settings for personal users are an effective measure against user enumeration
  • Companies could limit information exposure such as in out-of-office notes by disallowing communication to untrusted external domains

User account types

Before we dive into the enumeration flow, let's discuss different account types within the microsoft ecosystem first.

Work and school accounts

"Work and school accounts" or "organizational" accounts are tied to an organization and are created by that organization on behalf of the users. Usually these accounts are connected to an Azure Active Directory and could be linked to different services like Microsoft 365.

In order to use Teams, users need to be assigned a license that includes the use of Microsoft Teams, e.g. "Microsoft 365 Business Basic".

Microsoft Accounts (MSA) or personal accounts

Microsoft Accounts refer to the personal accounts that you could register yourself. This type of accounts was formerly known as LiveIDs - you might remember those from a long time ago. Did you register an hotmail.com account in the past? That's a (personal) Microsoft Account.

Personal accounts don't require a dedicated Teams license, however users need to login to Teams at least once in order to make that account available for Teams.

Teams Endpoints

Teams communicates through a series of different endpoints. For user enumeration from an external pespective two classes of endpoints are of particular interest:

The "user search" endpoints externalsearchv3 and searchUsers could be used to request general information about Teams-enrolled user accounts.

The getpresence endpoint is used to query a users availability as it appears in Teams like "Available", "Busy", "Away", etc. In this article these statuses are referred to as "presence information".

Besides these two classes of endpoints, there is also a difference in the domain that is contacted, either "microsoft.com" or "live.com". Which one is selected depends on the type of the user account:

Teams login flow
Teams login flow

As shown above the primary entrypoint after visiting "teams.microsoft.com" is "login.microsoftonline.com". If a personal account is used, all subsequent traffic is then forwarded to "*.teams.live.com". Corporate/Organizational accounts are redirected to "*.teams.microsoft.com".

Corporate accounts could query general user information on "teams.microsoft.com" while presence information is available on "presence.teams.microsoft".com

Personal accounts fetch user information via "teams.live.com" and presence information via "presence.live.com".

Fetching user information

The examples below demonstrate how user information could be fetched using different endpoints and account types.

General user information - Organizational accounts

The first example below shows the externalsearchv3 endpoint on "teams.microsoft.com". This API is called if you search for external users within your teams client. The search term is embedded within the URL, in this case user1@domain:

GET /api/mt/emea/beta/users/user1@domain/externalsearchv3?includeTFLUsers=true HTTP/2
Host: teams.microsoft.com
X-Ms-Client-Version: 1415/1.0.0.2023032504
Authorization: Bearer eyJ0eXAiOiJKV1QiLCJ[...]0ceEUXg
User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/111.0.0.0 Safari/537.36

The result below shows a positive result. The JSON structure in the HTTP body contains information about the identified user, including tenantId, userPrincipalName and the displayName.

HTTP/2 200 OK
Cache-Control: no-cache, no-store
Content-Length: 519
Content-Type: application/json; charset=utf-8
Access-Control-Expose-Headers: X-ServerRequestId
X-Serverrequestid: [REDACTED]
X-Machinename: mtsvc00000C
Strict-Transport-Security: max-age=31536000; includeSubDomains
X-Cache: CONFIG_NOCACHE
X-Msedge-Ref: Ref A: [REDACTED] Ref B: [REDACTED] Ref C: 2023-03-31T14:02:21Z
Date: Fri, 31 Mar 2023 14:02:20 GMT

[
  {
    "tenantId":"77334567-1111-4321-0000-123456789012",
    "isShortProfile":false,
    "accountEnabled":true,
    "featureSettings":{
      "coExistenceMode":"TeamsOnly"
    },
    "userPrincipalName":"user1@domain",
    "givenName":"user1@domain",
    "surname":"",
    "email":"user1@domain",
    "tenantName":"Test Corp",
    "displayName":"User1",
    "type":"Federated",
    "mri":"8:orgid:12345678-1234-4321-1234-123456789012","objectId":"12345678-1234-4321-1234-123456789012"
  }
]

What happens if we search for a non-existing user like doesnotexist@domain instead?

The response contains an empty JSON list. This is the default behaviour as no user was found.

HTTP/2 200 OK
Cache-Control: no-cache, no-store
Content-Length: 2
Content-Type: application/json; charset=utf-8
Access-Control-Expose-Headers: X-ServerRequestId
X-Serverrequestid: [REDACTED]
X-Machinename: mtsvc000009
Strict-Transport-Security: max-age=31536000; includeSubDomains
X-Cache: CONFIG_NOCACHE
X-Msedge-Ref: Ref A: [REDACTED] Ref B: [REDACTED] Ref C: 2023-03-31T14:03:00Z
Date: Fri, 31 Mar 2023 14:03:00 GMT

[]

By default organizational user accounts are visible to external organizational users. Whether accounts exist or not could be checked using the mechanisms described above. In order to restrict external visibility Teams administrators could configure whether users can communicate with external organizations. This setting could be found within the Teams Admin Panel and is shown in the screenshot below (drop-down menu on the top).

Teams settings for external communication
Teams settings for external communication

In total the following options are available to restrict access to external domains:

Settings to control external communications
Settings to control external communications

So what happens if external domains are blocked and the same search is performed again?

HTTP/2 403 Forbidden
Cache-Control: no-cache, no-store
Content-Length: 25
Content-Type: application/json; charset=utf-8
Access-Control-Expose-Headers: X-ServerRequestId
X-Serverrequestid: [REDACTED]
X-Machinename: mtsvc000002
Strict-Transport-Security: max-age=31536000; includeSubDomains
X-Cache: CONFIG_NOCACHE
X-Msedge-Ref: Ref A: [REDACTED] Ref B: [REDACTED] Ref C: 2023-03-31T14:03:27Z
Date: Fri, 31 Mar 2023 14:03:27 GMT

{
  "errorCode":"Forbidden"
}

The externalsearchv3 endpoint returns HTTP status code 403 - the JSON structure that we have seen before does not show up. If we search for a non-existing user, the endpoint still returns code 200 and an empty JSON list, like in the first scenario.

Although no detailed user information is provided within the response, the status code 403 is still different from code 200 of a non-existing user, which means that user enumeration is still possible despite all external domains being blocked.

While the visibility settings for corporate Teams tenants above do not prevent user enumeration, let's take a look at the visibility settings that personal users could set for their accounts:

Privacy settings for personal accounts
Privacy settings for personal accounts

This dialogue is shown when clicking on the own username in the top-right corner while logged in with a personal account.

By default all personal accounts could be found when searching for their email addresses. In the next example this setting is disabled and the same search is performed again:

HTTP/2 200 OK
Cache-Control: no-cache, no-store
Content-Length: 2
Content-Type: application/json; charset=utf-8
Access-Control-Expose-Headers: X-ServerRequestId
X-Serverrequestid: [REDACTED]
X-Machinename: mtsvc000009
Strict-Transport-Security: max-age=31536000; includeSubDomains
X-Cache: CONFIG_NOCACHE
X-Msedge-Ref: Ref A: [REDACTED] Ref B: [REDACTED] Ref C: 2023-03-31T15:11:05Z
Date: Fri, 31 Mar 2023 15:11:05 GMT

[]

This time an empty JSON response is returned - just like in the scenario of a non-existing corporate user. This behaviour is interesting as it indicates that the "privacy" mechanism for personal users is more powerful than the "block external communication" feature for corporate users.

User presence information - Organizational accounts

After obtaining basic information about a user, the next requests are sent to the presence API to get information about the availability status, like the ones shown below:

Presence statuses in Teams
Presence statuses in Teams

The following request is sent to the API on presence.teams.microsoft.com. Unlike the previous API, this endpoint does not use the users email address but the MRI (Message Resource Identifier) that deals as a user id for that user:

POST /v1/presence/getpresence/ HTTP/2
Host: presence.teams.microsoft.com
Content-Length: 79
Authorization: Bearer eyJ0eXAiOiJKV1QiLCJ[...]0ceEUXg
Content-Type: application/json


[
  {
    "mri":"8:orgid:12345678-1234-4321-1234-123456789012",
    "etag":"A0102351574"
  }
]

The response below shows a JSON structure that contains presence information about the queried user. Besides availability information (in this case "Busy") it also contains the device type.

HTTP/2 200 OK
Content-Type: application/json; charset=utf-8
Server: Microsoft-HTTPAPI/2.0
X-Ms-Correlation-Id: [REDACTED]
Access-Control-Expose-Headers: Location,Content-Length,x-ms-correlation-id
Date: Fri, 31 Mar 2023 14:07:27 GMT
Content-Length: 247

[
  {
    "mri":"8:orgid:12345678-1234-4321-1234-123456789012",
    "presence":{
      "sourceNetwork":"Federated",
      "capabilities":[
        "Audio",
        "Video"
      ],
      "availability":"Busy",
      "activity":"Busy",
      "deviceType":"Desktop"
    },
    "etagMatch":false,
    "etag":"A0102434763",
    "status":20000
  }
]

Information like this provides attackers valuable information that could be used in targeted attacks against users.

Employees that are absent might set an out-of-office message. This message usually shows up after sending an email to that employee, but is also displayed within Teams when trying to send a message to that user.

Let's query the presence API again - this time a MRI is chosen that belongs to a user who added an out-of-office note:

HTTP/2 200 OK
Content-Type: application/json; charset=utf-8
Server: Microsoft-HTTPAPI/2.0
X-Ms-Correlation-Id: [REDACTED]
Access-Control-Expose-Headers: Location,Content-Length,x-ms-correlation-id
Date: Fri, 31 Mar 2023 14:11:53 GMT
Content-Length: 583

[
  {
    "mri":"8:orgid:12345678-1234-4321-1234-123456789012",
    "presence":{
      "sourceNetwork":"Federated",
      "calendarData":{
        "outOfOfficeNote":{
          "message":"I am out of the office until April 4th with limited access to my email. I will respond to your email after my return. Kind regards, John",
          "publishTime":"2023-03-25T13:51:46.168Z",
          "expiry":"2023-04-04T14:00:00Z"
        },
        "isOutOfOffice":true
      },
      "capabilities":[
        "Audio","Video"
      ],
      "availability":"Away",
      "activity":"Away",
      "deviceType":"Mobile"
    },
    "etagMatch":false,
    "etag":"A0102434850",
    "status":20000
  }
]

The JSON contains an outOfOfficeNote structure including the message that the user put there. Depending on the contents of these messages attackers might be able to retrieve sensitive information about other employee names, phone numbers, websites etc.

Organizations that want to reduce the risk of information exposure could block communications to external domains, like described for the externalsearchv3 endpoint.

Let's check what happens if presence information is queried when external domains are blocked:


HTTP/2 200 OK
Content-Type: application/json; charset=utf-8
Server: Microsoft-HTTPAPI/2.0
X-Ms-Correlation-Id: [REDACTED]
Access-Control-Expose-Headers: Location,Content-Length,x-ms-correlation-id
Date: Fri, 31 Mar 2023 14:09:02 GMT
Content-Length: 195

[
  {
    "mri":"8:orgid:12345678-1234-4321-1234-123456789012",
    "presence":{
      "sourceNetwork":"Unknown",
      "availability":"Offline",
      "activity":"Offline"
    },
    "etagMatch":false,
    "etag":"A0102434602",
    "status":20000
  }
]

The response contains a JSON structure, like in the previous example. However in this case the availability shows up as "Offline", instead of "Available" or "Busy". But how could this be distinguished from a user that is indeed offline? The field sourceNetwork is set to Unknown while it was set to Federated when external domains were still allowed.

In order to find out if a domain is blocking other external domains it is sufficient to call the externalsearchv3 endpoint - if the search returns HTTP code 403 querying the presence endpoint does not add much value and could be skipped.

General user information - Personal accounts

As mentioned before, personal Teams accounts use a completely different domain, "teams.live.com". Although the API is similar to the one on "teams.microsoft.com" it's not exactly the same.

When using the search bar within Teams to find other users the searchUsers endpoint is called:

POST /api/mt/beta/users/searchUsers HTTP/2
Host: teams.live.com
Content-Length: 61
Authorization: Bearer Ew[...]wI=
X-Skypetoken: eyJhbGciOiJS[...]0JaZtQ
Content-Type: application/json;charset=UTF-8


{
  "emails":[
    "user1@domain"
  ],
  "phones":[
  ]
}

You might have noticed, that an additional X-Skypetoken header is set in the request. The Bearer token within the Authorization header that was also used for authentication on "teams.microsoft.com" looks different on "teams.live.com". In the request above you can see that the token for "live.com" is not a JWT.

After submitting the query, the server responds with the search results:

HTTP/2 200 OK
Cache-Control: no-cache, no-store
Content-Type: application/json; charset=utf-8
Access-Control-Expose-Headers: X-ServerRequestId
X-Serverrequestid: [REDACTED]
X-Machinename: mtsvc000003
Strict-Transport-Security: max-age=31536000; includeSubDomains
X-Cache: CONFIG_NOCACHE
X-Msedge-Ref: Ref A: [REDACTED] Ref B: [REDACTED] Ref C: 2023-03-31T13:49:22Z
Date: Fri, 31 Mar 2023 13:49:23 GMT

{
  "user1@domain":{
    "userProfiles":[
      {
        "isShortProfile":false,
        "isBlocked":false,
        "tenantId":"77334567-1111-4321-0000-123456789012",
        "userPrincipalName":"user1@domain",
        "email":"user1@domain",
        "tenantName":"Test Corp",
        "displayName":"User1",
        "type":"Federated",
        "mri":"8:orgid:12345678-1234-4321-1234-123456789012"
      }
    ],
    "status":"Success"
  }
}

The endpoint returns a structure that is similar to the one for organizational accounts, but looks a bit different. Most information about the user however remain the same.

How does the endpoint respond if a non-existing user is queried?

HTTP/2 200 OK
Cache-Control: no-cache, no-store
Content-Type: application/json; charset=utf-8
Access-Control-Expose-Headers: X-ServerRequestId
X-Serverrequestid: [REDACTED]
X-Machinename: mtsvc00000C
Strict-Transport-Security: max-age=31536000; includeSubDomains
X-Cache: CONFIG_NOCACHE
X-Msedge-Ref: Ref A: [REDACTED] Ref B: [REDACTED] Ref C: 2023-03-31T13:56:17Z
Date: Fri, 31 Mar 2023 13:56:16 GMT

{
  "doesnotexist@domain":{
    "userProfiles":[
    ],
    "status":"NotFound"
  }
}

While "teams.microsoft.com" returned an empty JSON array, the structure above contains some more items. The status NotFound indicates that the user does not exist.

If you recall the Teams admin settings screenshot, there is an option to block communication with users that are not managed by an organization. This option is similar to "Block all external domains" but refers to messages from and to personal user accounts.

So what happens if we use our personal account to query a user belonging to an organization that blocks non-managed senders?

HTTP/2 200 OK
Cache-Control: no-cache, no-store
Content-Type: application/json; charset=utf-8
Access-Control-Expose-Headers: X-ServerRequestId
X-Serverrequestid: [REDACTED]
X-Machinename: mtsvc00000C
Strict-Transport-Security: max-age=31536000; includeSubDomains
X-Cache: CONFIG_NOCACHE
X-Msedge-Ref: Ref A: [REDACTED] Ref B: [REDACTED] Ref C: 2023-03-31T13:56:17Z
Date: Fri, 31 Mar 2023 13:58:26 GMT

{
  "blocks_personal_accounts@domain":{
    "userProfiles":[
    ],
    "status":"NotFound"
  }
}

The result is the same as for non-existing users. This means that "teams.live.com" can't be used to distinguish between non-existing users and users whose organization blocks external personal accounts. The same applies when searching for personal accounts who restricted their visibility when using the search feature.

It can still be very useful to use the "live.com" endpoints: Some organizations block external domains, but forget to also block personal accounts. In such a scenario externalsearchv3 on "teams.microsoft.com" might return HTTP code 403, while searchUsers on "teams.live.com" returns full user information.

User presence information - Personal accounts

The presence API on "presence.teams.live.com" is very similar to the one on "presence.teams.microsoft.com" and requires a MRI. For authentication it only relies on the X-Skypetoken:

POST /v1/presence/getpresence/ HTTP/2
Host: presence.teams.live.com
Content-Length: 42
X-Skypetoken: eyJhbGciOiJS[...]0JaZtQ
X-Ms-Client-Consumer-Type: teams4life
Content-Type: application/json


[
  {
    "mri":"8:orgid:12345678-1234-4321-1234-123456789012"
  }
]

The response below contains a JSON structure that looks familiar:

HTTP/2 200 OK
Content-Type: application/json; charset=utf-8
Server: Microsoft-HTTPAPI/2.0
X-Ms-Correlation-Id: [REDACTED]
Access-Control-Expose-Headers: Location,Content-Length,x-ms-correlation-id
Date: Fri, 31 Mar 2023 14:01:08 GMT
Content-Length: 179

[
  {
    "mri":"8:orgid:12345678-1234-4321-1234-123456789012",
    "presence":{
      "sourceNetwork":"Unknown",
      "availability":"PresenceUnknown",
      "activity":"PresenceUnknown"
    },
    "etagMatch":false,
    "etag":"A0102434169",
    "status":20000
  }
]

The availability and activity fields show "PresenceUnknown" although communication with non-organizational accounts is allowed. This is because personal accounts are much more restricted in what they are allowed to see. A personal teams account is only allowed to query the presence of an organizational user if the organizational user has messaged the personal account before. This is a feature to protect the privacy of corporate accounts.

Personal accounts are therefore not suitable to query presence information of corporate users.

User enumeration with TeamsEnum

All actions described earlier can be performed using TeamsEnum. You can find the current version on https://github.com/sse-secure-systems/TeamsEnum.

A detailed guideline on how to use the tool is available in the README.

Most enumeration tools for Teams accounts use token-based authentication which is a good way if you don't want to enter username and password and if you plan to run TeamsEnum more than once in a short timeframe.

For convenience I also implemented device code authentication (useful if your account requires MFA) and password-based authentication, based on the "Microsoft Authentication Library" (MSAL) - So just pick whatever mechanism suits you best.

After choosing the authentication mechanism you could either try to enumerate a single user, or provide a list of email addresses to go through.

If everything was entered correctly the process starts and you should see some results on the screen:

Invocation of TeamsEnum
Invocation of TeamsEnum

The output is a very brief version of what you actually get from the server. If you add the -o parameter all results are written to disk in JSON format. Just use tools like jq to parse and print the results:

JSON output of TeamsEnum
JSON output of TeamsEnum

Summary

The previous sections have dealt with ways to enumerate the existence of Microsoft Teams users using personal and organizational accounts. Furthermore user presence information was fetched from the endpoints in both ecosystems. Time for a quick summary:

Existence of user accounts

Corporate user accounts can always be identified by other corporate user accounts, even if external domains are blocked. This is possible due to different HTTP status codes (200 vs. 403).

Corporate user accounts can only be identified by personal accounts, if the company allows communication to non-managed accounts. This is because the endpoint does not distinguish between non-existing corporate users and those whose organisations block personal accounts.

Personal accounts could be found by corporate and personal users by default. Personal users who don't want to appear in search results could disable the switch in "Manage how people can find you" (see section "General user information - Organizational accounts"), which is an effective measure against user enumeration.

Details of user accounts

Companies that want to prevent others from obtaining information like the Display Name, UserPrincipalName etc. or presence information could block external domains and non-managed user accounts within the Teams Admin Center.

Details of personal accounts can't be fetched any longer when disabling the switch in the "Manage how people can find you" setting (see above).

Information exposure through Out-of-office notes

Sensitive information should not be included in (external) out-of-office notes since these could be easily fetched and reveal information about inner workings of the company, phone numbers and URLs to internal websites.

Caveats when blocking external domains

Blocking communications to external domains and non-managed accounts within the Teams Admin Panel is an effective way to prevent attackers from fetching additional user details, however this remediation might be too restrictive if organizations frequently exchange information with external parties via Teams.

References

[1]: https://aadinternals.com/post/just-looking/

[2]: https://github.com/LMGsec/o365creeper

[3]: https://danielchronlund.com/2020/03/13/automatic-azure-ad-user-account-enumeration-with-powershell-scary-stuff/

[4]: https://learn.microsoft.com/en-us/azure/active-directory/hybrid/how-to-connect-sso

[5]: https://www.immunit.ch/blog/2021/07/05/microsoft-teams-user-enumeration/

[6]: https://github.com/immunIT/TeamsUserEnum

[7]: https://github.com/sse-secure-systems/TeamsEnum

Bastian Kanbach
Bastian is part of our Offensive Security Team delivering tailored security assessments and Red Team exercises that fit the requirements of our clients. He specializes in network and infrastructure security.