THM Hackfinity Reversing Challenge Writeup: Compute Some Magic

Challenge Overview

The "Compute Some Magic" challenge was a reverse-engineering task involving a 64-bit ELF binary running as a network service on 10.10.184.40:9003. The goal was to interact with the server, provide the correct input (a "magic spell"), and retrieve the flag. The binary was provided (or its disassembly was analyzed), and the challenge required understanding the program’s logic to determine the correct input that would trigger the flag to be sent.

The flag format was expected to be THM{...}, as this was a TryHackMe challenge. The final flag retrieved was THM{s0m3_mag1c_that.can_b3_computed}.


Initial Analysis

Step 1: Connect to the Server

I started by connecting to the server using netcat to understand its behavior:

nc 10.10.184.40 9003

The server responded with the prompt "Compute some magic!", indicating that I needed to provide a "magic spell" as input. I initially tried the input gandalf, but the server didn’t respond with the flag, suggesting the input was incorrect.

Step 2: Analyze the Binary

The binary’s disassembly and decompiled code were provided, revealing it to be a 64-bit ELF executable for Linux. Key sections and functions were identified:

ELF Structure:

  • The binary had standard ELF sections like .text, .plt, .data, and .bss.
  • Imported functions included socket, bind, listen, accept, and send (indicating a network server), as well as fopen, fread, and fclose (suggesting file I/O, likely for reading the flag).

Key Functions:

  • main: Sets up a TCP server on port 9003, accepts client connections, and processes input.
  • sendBanner: Sends the "Compute some magic!" prompt to the client.
  • checkSpell: Validates the user’s input and determines whether to proceed.
  • read_flag: Reads the flag from a file (flag.txt) and sends it to the client.

Main Function

The main function (decompiled) revealed the program’s flow:

void main(void) {
    int local_4c; // Server socket
    int local_48; // Client socket
    int local_44; // Result of checkSpell
    size_t local_40; // Length of input
    struct sockaddr local_38; // Socket address structure
    char local_28[24]; // Input buffer
    socklen_t local_50; // Placeholder for accept length argument

    while (true) {
        local_4c = socket(AF_INET, SOCK_STREAM, 0); // Create TCP socket (using constants for clarity)

        // Set socket options, bind to port 9003, and listen
        local_38.sa_family = AF_INET; // AF_INET (usually 2)
        // Assuming local_38 is sockaddr_in structure
        ((struct sockaddr_in *)&local_38)->sin_port = htons(9003); // Port 9003 (0x232b)
        ((struct sockaddr_in *)&local_38)->sin_addr.s_addr = INADDR_ANY; // Bind to any interface

        bind(local_4c, &local_38, sizeof(local_38)); // Use sizeof structure
        listen(local_4c, 3);

        local_50 = sizeof(local_38); // Initialize length for accept
        local_48 = accept(local_4c, &local_38, &local_50); // Accept client

        sendBanner(local_48); // Send "Compute some magic!"
        read(local_48, local_28, 0x10); // Read up to 16 bytes of input

        // Null-terminate the buffer safely after read
        local_28[15] = '\0'; // Ensure null termination if 16 bytes read
        // Find potential newline and remove it
        char *newline = strchr(local_28, '\n');
        if (newline) {
            *newline = '\0';
        }

        local_40 = strlen(local_28); // Calculate length after potential newline removal

        local_44 = checkSpell(local_28, local_48); // Validate input

        if (local_44 == 1) {
            puts("\nSpell check successful.");
        } else {
            puts("\nSpell check failed.");
        }
        close(local_48);
        close(local_4c);
    }
}
  • The server listens on port 9003, accepts a connection, sends a banner, reads up to 16 bytes of input into local_28, and calls checkSpell to validate the input.
  • If checkSpell returns 1, it prints "Spell check successful."; otherwise, it prints "Spell check failed.".
  • Notably, main doesn’t directly call read_flag, suggesting that checkSpell (or a function it calls) is responsible for retrieving the flag.

read_flag Function

The read_flag function’s disassembly showed it opens flag.txt, reads its contents, and sends them to the client:

// Pseudocode (I forgot to save the code)
void read_flag(int client_socket) {
    char flag_buffer[128]; // Assumming buffer size
    FILE *file = fopen("flag.txt", "r");
    if (file != NULL) {
        if (fgets(flag_buffer, sizeof(flag_buffer), file) != NULL) {
            send(client_socket, flag_buffer, strlen(flag_buffer), 0);
        }
        fclose(file);
    }
}

This confirmed that the flag was stored in flag.txt, and read_flag would send it over the socket if called.

checkSpell Function

The checkSpell function was the key to solving the challenge:

int checkSpell(char *input_spell, int client_socket) {
    size_t spell_len;
    int result;

    spell_len = strlen(input_spell);

    if (spell_len < 1) {
        puts("Empty string.");
        result = 0;
    } else {
        switch (input_spell[0]) { // Check the first character
            case 'A': func_1(input_spell, client_socket); break;
            case 'B': func_2(input_spell, client_socket); break;
            case 'C': func_3(input_spell, client_socket); break;
            case 'D': func_4(input_spell, client_socket); break;
            case 'E': func_5(input_spell, client_socket); break;
            case 'F': func_6(); break;
            case 'G': func_7(input_spell, client_socket); break;
            case 'H': func_8(input_spell, client_socket); break;
            case 'I': func_9(); break;
            case 'J': func_11(); break;
            case 'K': func_12(); break;
            case 'L': func_13(input_spell, client_socket); break;
            case 'M': func_14(); break;
            case 'N': func_15(); break;
            case 'O': func_16(); break;
            case 'P': func_17(input_spell, client_socket); break;
            case 'Q': func_17(input_spell, client_socket); break; // Note: P and Q call the same function
            // R is missing
            case 'S': func_19(); break;
            case 'T': func_20(); break;
            case 'U': func_21(); break;
            case 'V': func_22(); break;
            case 'W': func_23(); break;
            case 'X': func_24(input_spell, client_socket); break; // This likely calls read_flag
            case 'Y': func_25(input_spell, client_socket); break;
            case 'Z': func_26(input_spell, client_socket); break;
            default:
                always(); // Called for non-uppercase letters or 'R'
                return 0; // Spell check fails
        }
        // If a case matches (and doesn't return early), spell check succeeds
        result = 1;
    }
    return result;
}
  • checkSpell checks the first character of the input (input_spell).
  • If the input is empty, it returns 0 (failing the check).
  • Otherwise, it uses a switch statement to handle uppercase letters A to Z (except R).
  • For each matching letter, it calls a corresponding function (func_1 to func_26).
  • If the first character isn’t A to Z (or is R), it calls always() and returns 0.
  • If a function is called (and the switch case completes), checkSpell returns 1, leading to "Spell check successful." in main.

Solving the Challenge

Step 3: Test Initial Inputs

My first input, gandalf, failed because it started with a lowercase g. The switch statement in checkSpell only handles uppercase A to Z, so gandalf triggered the default case, called always(), and returned 0, resulting in "Spell check failed.".

I then tried Gandalf (capitalizing the first letter):

nc 10.10.184.40 9003

(Server responds: Compute some magic!)
(Input: Gandalf)

Server Output:

Spell check successful.
  • This triggered func_7 (for G) and returned 1, printing "Spell check successful.".
  • However, no flag was sent, indicating that func_7 didn’t call read_flag.

Step 4: Hypothesize the Path to the Flag

Since checkSpell returned 1 for any input starting with A to Z (except R), but Gandalf didn’t yield the flag, I hypothesized that:

  • One of the func_X functions (called by checkSpell) must call read_flag.
  • I needed to test inputs starting with each uppercase letter (A to Z, excluding R) to find which func_X function sends the flag.

Step 5: Automate Testing with a Script

To systematically test inputs, I wrote a Python script to try a magic-related word starting with each applicable uppercase letter:

import socket
import sys # For better error output

host = "10.10.184.40"
port = 9003
spells = {
    'A': "Abracadabra", 'B': "Bazooka", 'C': "Charm",
    'D': "Dumbledore", 'E': "Expelliarmus", 'F': "Fireball",
    'G': "Gandalf", 'H': "Hocuspocus", 'I': "Incendio",
    'J': "Jinx", 'K': "Kappa", 'L': "Lumos", 'M': "Magic",
    'N': "Nox", 'O': "Obliviate", 'P': "Protego",
    'Q': "Quidditch", 'S': "Stupefy", 'T': "Transfigure", # Skipping R
    'U': "Unicorn", 'V': "Voldemort", 'W': "Wingardium",
    'X': "Xeno", 'Y': "Yeti", 'Z': "Zephyr"
}

flag_found = False

for letter, spell in spells.items():
    if flag_found:
        break
    print(f"--- Testing spell: {spell} ---")
    try:
        with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
            s.settimeout(5) # Set a general timeout
            s.connect((host, port))

            banner = s.recv(1024).decode()
            print(f"Received Banner: {banner.strip()}")

            print(f"Sending Spell: {spell}")
            s.sendall((spell + "\n").encode()) # Use sendall for reliability

            # Receive response (could be success message or flag)
            response = ""
            try:
                # Loop to receive potentially fragmented data
                while True:
                    chunk = s.recv(1024)
                    if not chunk:
                        break # Connection closed
                    response += chunk.decode()
                    # Check if flag format is in the current response
                    if "THM{" in response:
                        print(f"FLAG FOUND with spell '{spell}': {response.strip()}")
                        flag_found = True
                        break # Exit inner loop
                    # Check for common end-of-message markers if needed
                    if "successful." in response or "failed." in response:
                         break # Stop receiving if known message received
            except socket.timeout:
                 print("Socket timed out waiting for response.")
            except Exception as e:
                print(f"Error during receive for spell '{spell}': {e}", file=sys.stderr)

            print(f"Received Response: {response.strip()}")

            # Double check if flag was found in the loop above
            if flag_found:
                 break # Exit outer loop

            if "successful" in response.lower() and not flag_found:
                print(f"Spell '{spell}' was successful, but no flag detected in initial response.")
                # Optional: Try one more receive just in case? Usually not needed if flag replaces success msg.
                # try:
                #     s.settimeout(1)
                #     extra_data = s.recv(1024).decode()
                #     if extra_data and "THM{" in extra_data:
                #          print(f"FLAG FOUND in subsequent receive for spell '{spell}': {extra_data.strip()}")
                #          flag_found = True
                # except socket.timeout:
                #     pass # No extra data

    except socket.timeout:
        print(f"Connection timed out for spell '{spell}'", file=sys.stderr)
    except socket.error as e:
        print(f"Socket error for spell '{spell}': {e}", file=sys.stderr)
    except Exception as e:
        print(f"An unexpected error occurred for spell '{spell}': {e}", file=sys.stderr)

if not flag_found:
    print("\nFlag not found after testing all spells.")

Running the script produced output similar to this, eventually finding the flag:

--- Testing spell: Wingardium ---
Received Banner: Compute some magic!
Sending Spell: Wingardium
Received Response: 
Spell check successful.
Spell 'Wingardium' was successful, but no flag detected in initial response.
--- Testing spell: Xeno ---
Received Banner: Compute some magic!
Sending Spell: Xeno
FLAG FOUND with spell 'Xeno': THM{s0m3_mag1c_that.can_b3_computed}

Step 6: Retrieve the Flag

The input Xeno (starting with X) triggered func_24 in checkSpell. Unlike the other functions tested, func_24 presumably called read_flag, which sent the flag directly over the socket instead of the "Spell check successful." message. The flag was:

THM{s0m3_mag1c_that.can_b3_computed}

Final Answer

The flag for the "Compute Some Magic" challenge is:

THM{s0m3_mag1c_that.can_b3_computed}

Alternative Approaches

While the script worked, there were other ways to solve the challenge more efficiently:

  1. Static Analysis:
    • Use a decompiler (e.g., Ghidra or IDA) to analyze the binary.
    • Find the address or symbol for read_flag and look for cross-references (XREFs) to see which function calls it.
    • Following the call graph back would lead from read_flag to func_24, and then to the case 'X' block within checkSpell.
  2. Dynamic Analysis:
    • Run the binary in a debugger (e.g., gdb):
      gdb ./compute_magic_binary # Replace with actual binary name
      
    • Set a breakpoint at the read_flag function and run the program:
      (gdb) break read_flag
      (gdb) run
      
    • In a separate terminal, connect with nc localhost 9003 (assuming you're running the binary locally) and test inputs (A, B, C..., X). When you send Xeno (or any string starting with X), the breakpoint in gdb should be hit, confirming that X triggers the flag read.
  3. Manual Testing:
    • Manually connect using nc and test inputs starting with each letter (A through Z, skipping R). While slower, this would eventually lead to sending X... and receiving the flag.

Lessons Learned

  1. Understand the Program Flow:
    • Analyzing the main and checkSpell functions revealed the core logic: a server validating input based on the first character and dispatching to different functions.
    • Recognizing that read_flag wasn't called directly in main pointed towards the functions called by checkSpell as the path to the flag.
  2. Leverage Automation:
    • The large switch statement in checkSpell strongly suggested a brute-force or systematic testing approach. Scripting the connection and input testing saved significant time and reduced potential errors compared to manual testing.
  3. Pay Attention to Details:
    • The initial failure with gandalf (lowercase g) highlighted the case-sensitivity of the switch statement (A to Z only).
    • The theme ("Compute some magic!") guided the choice of test inputs, although the specific word (Xeno) was less important than the starting letter (X), found through systematic testing.
  4. Use Appropriate Tools:
    • For future, similar challenges, using a decompiler (static analysis) or debugger (dynamic analysis) to directly identify the function calling read_flag would likely be much faster than iterating through all possibilities with a script.

Conclusion

The "Compute Some Magic" challenge was a good exercise in basic reverse-engineering and network service interaction. By combining static analysis (understanding the C code structure and function roles like checkSpell and read_flag) with systematic testing (either manually or via script), the condition required to trigger the flag (X... input) could be found, leading to the flag: THM{s0m3_mag1c_that_can_b3_computed}. This challenge demonstrated how understanding program control flow is crucial in CTF reversing tasks.